diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 9d3c613e9..000000000 --- a/.editorconfig +++ /dev/null @@ -1,4 +0,0 @@ -root = true - -[*.{kt,kts}] -ktlint_standard_import-ordering = disabled \ No newline at end of file diff --git a/.github/docs-sync-sources.txt b/.github/docs-sync-sources.txt new file mode 100644 index 000000000..8600d6d62 --- /dev/null +++ b/.github/docs-sync-sources.txt @@ -0,0 +1,11 @@ +CONTRIBUTING.md +ROADMAP.md +STABILITY.md +docs/store6/important-defaults.md +docs/store6/invalidate-vs-clear.md +docs/store6/key-design.md +docs/store6/quickstart.md +llms.txt +store6-compose/README.md +store6-room/README.md +store6-sqldelight/README.md diff --git a/.github/workflows/KMMBridge-Release.yml b/.github/workflows/KMMBridge-Release.yml deleted file mode 100644 index d1a9a9b9e..000000000 --- a/.github/workflows/KMMBridge-Release.yml +++ /dev/null @@ -1,12 +0,0 @@ -# Publish the release XCFramework to a GitHub Release. -# Debug XCFrameworks are built locally on demand via `./gradlew :spmDevBuild`. -name: KMMBridge-Publish -on: - workflow_dispatch: - -jobs: - call-publish: - permissions: - contents: write - packages: write - uses: ./.github/workflows/create_swift_package.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9cd3b5a91..6d94df9c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,21 +2,29 @@ name: CI on: push: - branches: [ main ] + branches: [ main, store6 ] pull_request: - branches: [ main ] + branches: [ main, store6 ] + +permissions: + contents: read jobs: build-and-test: runs-on: ubuntu-latest - timeout-minutes: 30 + # The store6-mutations Lincheck budget measured ~58-59m locally and 2h40m-3h13m hosted + # at the current suite (and once 9h23m locally at an earlier revision). The default + # jvmTest excludes it via the build-gated filter in store6-mutations/build.gradle.kts + # (census-guarded below); the scheduled "Store6 full mutations JVM suite" workflow runs + # the full suite daily. + timeout-minutes: 120 strategy: fail-fast: false matrix: api-level: [ 29 ] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: # PR builds (including forks) check out the PR head from its source repo; # push builds fall back to the pushed ref on this repo. Without the @@ -31,7 +39,7 @@ jobs: persist-credentials: false - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: '17' @@ -42,22 +50,28 @@ jobs: - name: Grant execute permission for Gradlew run: chmod +x gradlew - - name: Build and Test with Coverage - run: ./gradlew clean build koverXmlReport --stacktrace --continue + - name: Build and Test + run: ./gradlew clean build --stacktrace + + - name: Census — default jvmTest must execute exactly the non-Lincheck suites + shell: bash + run: | + set -euo pipefail + expected=$(( $(find store6-mutations/src/commonTest store6-mutations/src/jvmTest -name '*Test.kt' | wc -l | tr -d ' ') - 1 )) + executed=$(find store6-mutations/build/test-results/jvmTest -name 'TEST-*.xml' | wc -l | tr -d ' ') + echo "expected=${expected} executed=${executed}" + [ "${expected}" -eq "${executed}" ] - - name: Upload Coverage to Codecov - # Secrets (including CODECOV_TOKEN) are not exposed to fork PRs, so the - # upload would fail under fail_ci_if_error. Skip it for forks; coverage is - # still uploaded and enforced for same-repo PRs and pushes to main. - if: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' }} - uses: codecov/codecov-action@v6 + - name: Upload test reports + if: ${{ failure() }} + uses: actions/upload-artifact@v4 with: - token: ${{ secrets.CODECOV_TOKEN }} - files: build/reports/kover/coverage.xml - flags: unittests - name: codecov-umbrella - fail_ci_if_error: true - verbose: true + name: test-reports-root-${{ github.run_id }}-${{ github.run_attempt }} + path: | + **/build/test-results/**/*.xml + **/build/reports/tests/** + if-no-files-found: warn + retention-days: 7 publish: if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'MobileNativeFoundation/Store' @@ -65,10 +79,12 @@ jobs: needs: build-and-test steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set up JDK 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: '17' @@ -81,7 +97,7 @@ jobs: - name: Retrieve Version run: | - echo "VERSION_NAME=$(grep -E '^store[[:space:]]*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2)" >> $GITHUB_ENV + echo "VERSION_NAME=$(grep -w 'VERSION_NAME' gradle.properties | cut -d'=' -f2)" >> $GITHUB_ENV - name: Publish to Maven Central (Central Portal) env: @@ -94,4 +110,4 @@ jobs: ./gradlew publishToMavenCentral else ./gradlew publishAndReleaseToMavenCentral - fi \ No newline at end of file + fi diff --git a/.github/workflows/create_swift_package.yml b/.github/workflows/create_swift_package.yml deleted file mode 100644 index 14f99e9e3..000000000 --- a/.github/workflows/create_swift_package.yml +++ /dev/null @@ -1,67 +0,0 @@ -# Based on: https://github.com/touchlab/KMMBridgeSPMQuickStart/blob/main/.github/workflows/Base-Publish.yml -# Publishes the release XCFrameworks to a GitHub Release. -# For debugging Kotlin from Xcode, build a debug XCFramework locally with -# `./gradlew :spmDevBuild` instead. -name: Base-Publish - -on: - workflow_call: - -permissions: - contents: write - packages: write - -jobs: - kmmbridgepublish: - concurrency: "kmmbridgepublish-${{ github.repository }}" - runs-on: macos-latest - steps: - - name: Checkout the repo with tags - uses: actions/checkout@v6 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Retrieve Version - id: versionPropertyValue - run: | - VERSION=$(grep -E '^store[[:space:]]*=' gradle/libs.versions.toml | head -1 | cut -d'"' -f2) - echo "propVal=$VERSION" >> $GITHUB_OUTPUT - - - name: Set up JDK 17 - uses: actions/setup-java@v5 - with: - distribution: 'zulu' - java-version: '17' - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v6 - - - name: Grant execute permission for Gradlew - run: chmod +x gradlew - - - name: Create or Find Artifact Release - id: devrelease - uses: softprops/action-gh-release@v2 - with: - token: ${{ secrets.GITHUB_TOKEN }} - tag_name: "${{ steps.versionPropertyValue.outputs.propVal }}" - - - name: Build and Publish - run: | - ./gradlew kmmBridgePublish \ - -PNATIVE_BUILD_TYPE=RELEASE \ - -PGITHUB_ARTIFACT_RELEASE_ID=${{ steps.devrelease.outputs.id }} \ - -PGITHUB_PUBLISH_TOKEN=${{ secrets.GITHUB_TOKEN }} \ - -PGITHUB_REPO=${{ github.repository }} \ - -PENABLE_PUBLISHING=true \ - --no-daemon --info --stacktrace - env: - GRADLE_OPTS: -Dkotlin.incremental=false -Dorg.gradle.jvmargs="-Xmx3g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=512m" - - - uses: touchlab/ga-update-release-tag@v1 - id: update-release-tag - with: - commitMessage: "KMP SPM package release for ${{ steps.versionPropertyValue.outputs.propVal }}" - tagMessage: "KMP release version ${{ steps.versionPropertyValue.outputs.propVal }}" - tagVersion: ${{ steps.versionPropertyValue.outputs.propVal }} diff --git a/.github/workflows/store6-benchmarks.yml b/.github/workflows/store6-benchmarks.yml new file mode 100644 index 000000000..d841ce858 --- /dev/null +++ b/.github/workflows/store6-benchmarks.yml @@ -0,0 +1,92 @@ +name: Store6 Benchmarks + +# Non-blocking measurement lane. Report-only: runs the smoke configuration and uploads JSON +# results. NO step asserts against a number — no numeric performance target has been adopted +# (see store6-benchmarks/README.md for the CI boundary). This workflow is deliberately OUTSIDE +# the exact-head-green ready-gate convention, which continues to mean: the Store6 and CI +# workflows green at the head. +on: + workflow_dispatch: + pull_request: + branches: [ main, store6 ] + paths: + - 'store6-benchmarks/**' + - '.github/workflows/store6-benchmarks.yml' + +permissions: + contents: read + +concurrency: + group: store6-benchmarks-${{ github.ref }} + cancel-in-progress: true + +jobs: + benchmarks-smoke: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout the repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Grant execute permission for Gradlew + run: chmod +x gradlew + + - name: Run smoke benchmarks (report-only; no thresholds) + run: ./gradlew :store6-benchmarks:smokeBenchmark --stacktrace + + - name: Summarize results + shell: bash + run: | + set -euo pipefail + reports_dir="store6-benchmarks/build/reports/benchmarks" + mapfile -t json_reports < <(find "${reports_dir}" -type f -name '*.json' -print | LC_ALL=C sort) + report_count="${#json_reports[@]}" + if [[ "${report_count}" -ne 1 ]]; then + echo "ERROR: expected exactly one benchmark JSON under ${reports_dir}; found ${report_count}" >&2 + if [[ "${report_count}" -gt 0 ]]; then + printf ' %s\n' "${json_reports[@]}" >&2 + fi + exit 1 + fi + json="${json_reports[0]}" + if ! jq -e ' + type == "array" and + length > 0 and + all(.[]; + type == "object" and + ((.benchmark | type) == "string") and + ((.benchmark | length) > 0) and + ((has("params") | not) or ((.params | type) == "object")) and + ((.primaryMetric | type) == "object") and + ((.primaryMetric.score | type) == "number") and + ((.primaryMetric.scoreUnit | type) == "string") and + ((.primaryMetric.scoreUnit | length) > 0) + ) + ' "${json}" >/dev/null; then + echo "ERROR: benchmark JSON failed structural validation: ${json}" >&2 + exit 1 + fi + echo "Results from ${json} (smoke-grade numbers; hosted-runner noise applies — see store6-benchmarks/README.md):" + jq -r '.[] | [.benchmark, + ((.params // {}) | to_entries | map("\(.key)=\(.value)") | join(",")), + (.primaryMetric.score | tostring), + .primaryMetric.scoreUnit] + | @tsv' "${json}" | column -t -s $'\t' + + - name: Upload benchmark results + uses: actions/upload-artifact@v4 + with: + name: store6-benchmarks-smoke-${{ github.run_id }} + path: store6-benchmarks/build/reports/benchmarks/ + if-no-files-found: error diff --git a/.github/workflows/store6-full-jvm.yml b/.github/workflows/store6-full-jvm.yml new file mode 100644 index 000000000..22a7145f8 --- /dev/null +++ b/.github/workflows/store6-full-jvm.yml @@ -0,0 +1,71 @@ +name: Store6 full mutations JVM suite + +on: + schedule: + - cron: '17 5 * * *' + workflow_dispatch: {} + +permissions: + contents: read + issues: write + +jobs: + full-mutations-jvm: + runs-on: ubuntu-latest + timeout-minutes: 360 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Grant execute permission for Gradlew + run: chmod +x gradlew + + - name: Run the FULL mutations JVM suite (Lincheck included) + run: ./gradlew :store6-mutations:jvmTest -Pstore6.fullJvmSuite --stacktrace + + - name: Census — no suite may be lost + shell: bash + run: | + set -euo pipefail + expected=$(find store6-mutations/src/commonTest store6-mutations/src/jvmTest -name '*Test.kt' | wc -l | tr -d ' ') + executed=$(find store6-mutations/build/test-results/jvmTest -name 'TEST-*.xml' | wc -l | tr -d ' ') + echo "expected=${expected} executed=${executed}" + [ "${expected}" -eq "${executed}" ] + + - name: Upload test results + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: full-jvm-results-${{ github.run_id }}-${{ github.run_attempt }} + path: | + store6-mutations/build/test-results/**/*.xml + store6-mutations/build/reports/tests/** + if-no-files-found: warn + retention-days: 14 + + - name: File the classification duty on failure + if: ${{ failure() }} + env: + GH_TOKEN: ${{ github.token }} + run: | + gh issue list -R "${GITHUB_REPOSITORY}" --state open \ + --search "Scheduled full mutations JVM suite red in:title" --json number \ + --jq '.[0].number' > /tmp/existing || true + body="Scheduled full-suite run ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID} failed. Classify per docs/v6 conduct (017 register / 031 job-cap class / novel). Never rerun for evidence." + if [ -s /tmp/existing ] && [ "$(cat /tmp/existing)" != "null" ] && [ -n "$(cat /tmp/existing)" ]; then + gh issue comment "$(cat /tmp/existing)" -R "${GITHUB_REPOSITORY}" --body "${body}" + else + gh issue create -R "${GITHUB_REPOSITORY}" \ + --title "Scheduled full mutations JVM suite red (${GITHUB_RUN_ID})" --body "${body}" + fi diff --git a/.github/workflows/store6.yml b/.github/workflows/store6.yml new file mode 100644 index 000000000..59c5b938b --- /dev/null +++ b/.github/workflows/store6.yml @@ -0,0 +1,801 @@ +name: Store6 + +on: + push: + branches: [ main, store6 ] + pull_request: + branches: [ main, store6 ] + types: [ opened, synchronize, reopened, labeled, unlabeled ] + +permissions: + contents: read + +concurrency: + group: store6-${{ github.ref }}-${{ (github.event.action == 'labeled' || github.event.action == 'unlabeled') && 'docs-sync-guard' || 'validation' }} + cancel-in-progress: true + +jobs: + docs-sync-guard: + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Require documentation sync acknowledgment + shell: bash + env: + DOCS_SYNC_ACK: ${{ contains(github.event.pull_request.labels.*.name, 'docs-sync-ack') }} + run: | + set -euo pipefail + source_list=.github/docs-sync-sources.txt + + if [[ ! -s "${source_list}" ]]; then + echo "ERROR: ${source_list} must be non-empty." >&2 + exit 1 + fi + if ! LC_ALL=C sort -c "${source_list}"; then + echo "ERROR: ${source_list} must be sorted." >&2 + exit 1 + fi + + changed_sources="$( + comm -12 \ + <(LC_ALL=C sort "${source_list}") \ + <(git diff --name-only origin/main...HEAD | LC_ALL=C sort) + )" + if [[ -z "${changed_sources}" || "${DOCS_SYNC_ACK}" == "true" ]]; then + exit 0 + fi + + printf 'Documentation sources changed:\n%s\n' "${changed_sources}" >&2 + cat >&2 <<'EOF' + This PR edits sources published to the docs site (listed in .github/docs-sync-sources.txt; pinned by store-docs evidence/T4-store6-source-lock.json). Merging makes the site stale until the docs repo re-pins. Add the 'docs-sync-ack' label to proceed; the docs repo's scheduled drift check will open the re-pin PR after merge. If your edit inserts or deletes lines in STABILITY.md, ROADMAP.md, store6-compose/README.md, or store6-sqldelight/README.md above an existing section, prefer appending — the site applies line-anchored publication transforms to these files. + EOF + exit 1 + + linux-build-test: + runs-on: ubuntu-latest + # The store6-mutations Lincheck budget measured ~58-59m locally and 2h40m-3h13m hosted + # at the current suite (and once 9h23m locally at an earlier revision). The default + # jvmTest excludes it via the build-gated filter in store6-mutations/build.gradle.kts + # (census-guarded below); the scheduled "Store6 full mutations JVM suite" workflow runs + # the full suite daily. + timeout-minutes: 120 + env: + # Kotlin JS/Wasm lock tasks must see one complete project graph across separate Gradle steps. + GRADLE_OPTS: -Dorg.gradle.configureondemand=false + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Cache Kotlin/Native compiler + uses: actions/cache@v4 + with: + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }} + restore-keys: | + ${{ runner.os }}-konan- + + - name: Grant execute permission for Gradlew + run: chmod +x gradlew + + - name: Build Store6 core + run: > + ./gradlew :store6-core:build :store6-testing:build :store6-sqldelight:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 quickstart + run: ./gradlew :store6-quickstart:run --stacktrace + + - name: Run Store6 sqldelight sample + run: ./gradlew :store6-sqldelight-sample:run --stacktrace + + - name: Build Store6 extension probe (seam-only consumer) + run: > + ./gradlew :store6-extension-probe:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Build Store6 compose and demo + run: > + ./gradlew :store6-compose:build :store6-compose-demo:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Verify Compose stability of core public types + shell: bash + run: | + set -euo pipefail + reports_dir=store6-compose-demo/build/compose-reports + # Force a complete, non-incremental report. Kotlin incremental compilation rewrites the + # stability report with ONLY the recompiled subset, and the report is an undeclared task + # output, so neither deleting it nor editing a source guarantees a whole-module snapshot. + # Discarding the module's build directory makes the next compile a full one. The demo + # module's compilations are also opted out of the build cache (see its build.gradle.kts) + # so this cannot be served from cache without running the compiler. + rm -rf store6-compose-demo/build + ./gradlew :store6-compose-demo:compileKotlin --stacktrace + if [[ "$(find "${reports_dir}" -name '*-composables.txt' 2>/dev/null | wc -l | tr -d ' ')" -eq 0 ]]; then + echo "ERROR: no composables report under ${reports_dir}" >&2 + echo "The compose metrics wiring in store6-compose-demo/build.gradle.kts was removed or renamed." >&2 + exit 1 + fi + # All reports are concatenated (deterministic; probes exist only in the main compilation, + # so tier counts stay exact regardless of how many reports the plugin writes — the test + # compilation writes a separate, empty *_test-composables.txt and never clobbers it). + # + # Every probe parameter must be rendered EXPLICITLY `stable`. Asserting the mere absence + # of `unstable` would pass silently on the compiler's third rendering — a bare, unprefixed + # `value: X` line, which it emits for unknown/runtime stability. + # + # iface_strict=1 asserts that the shipped conf renders every probe parameter stable, + # including the interface-typed and generic ones. Set it to 0 only if a toolchain bump + # makes interface-typed parameters unprovable, which downgrades the iface tier to + # skippable-only and reports the residual instead of failing. + find "${reports_dir}" -name '*-composables.txt' | sort | xargs cat | awk -v iface_strict=1 ' + /fun / { + tier = 0 + if ($0 ~ /ProbeStrict/) { tier = 1; strict += 1 } + else if ($0 ~ /ProbeIface/) { tier = 2; iface += 1 } + if (tier > 0 && $0 !~ /skippable/) { print "NOT SKIPPABLE: " $0; bad = 1 } + next + } + /^\)/ { tier = 0; next } + tier > 0 { + if ($1 != "stable") { + label = (tier == 1) ? "strict" : "iface" + if (tier == 1 || iface_strict) { + print "PARAM NOT STABLE (" label " tier): " $0 + bad = 1 + } else { + print "INFO: non-stable iface-tier param (allowed when iface_strict=0): " $0 + } + } + } + END { + if (strict != 8) { print "Expected 8 ProbeStrict composables, found " strict + 0; exit 1 } + if (iface != 5) { print "Expected 5 ProbeIface composables, found " iface + 0; exit 1 } + exit bad + 0 + } + ' + + - name: Build Store6 Room adapter + run: > + ./gradlew :store6-room:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 Room sample + run: ./gradlew :store6-room-sample:run --stacktrace + + - name: Build Store6 benchmarks + run: ./gradlew :store6-benchmarks:build --stacktrace + + - name: Build Store6 devtools modules and demo + run: > + ./gradlew :store6-devtools:build :store6-devtools-inspector:build :store6-devtools-demo:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Build Store6 mutations + run: > + ./gradlew :store6-mutations:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 mutations quickstart + run: ./gradlew :store6-mutations-quickstart:run --stacktrace + + - name: Census — default jvmTest must execute exactly the non-Lincheck suites + shell: bash + run: | + set -euo pipefail + expected=$(( $(find store6-mutations/src/commonTest store6-mutations/src/jvmTest -name '*Test.kt' | wc -l | tr -d ' ') - 1 )) + executed=$(find store6-mutations/build/test-results/jvmTest -name 'TEST-*.xml' | wc -l | tr -d ' ') + echo "expected=${expected} executed=${executed}" + [ "${expected}" -eq "${executed}" ] + + - name: Build Store6 mutation journal support + run: > + ./gradlew :store6-mutations-testing:build :store6-mutations-sqldelight:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Build Store6 paging-androidx + run: > + ./gradlew :store6-paging-androidx:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 paging sample + run: ./gradlew :store6-paging-androidx-sample:run --stacktrace + + - name: Build Store6 graphql + run: > + ./gradlew :store6-graphql:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 graphql sample + run: ./gradlew :store6-graphql-sample:run --stacktrace + + - name: Build Store6 realtime + run: > + ./gradlew :store6-realtime:build + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Run Store6 realtime sample + run: ./gradlew :store6-realtime-sample:run --stacktrace + + - name: Reject core-internal access from extension modules + shell: bash + run: | + set -euo pipefail + status=0 + for module in store6-extension-probe store6-testing store6-sqldelight store6-compose store6-compose-demo store6-room store6-benchmarks store6-devtools store6-devtools-inspector store6-devtools-demo store6-mutations store6-mutations-quickstart store6-mutations-testing store6-mutations-sqldelight store6-paging-androidx store6-paging-androidx/sample store6-graphql store6-graphql/sample store6-realtime store6-realtime/sample; do + source_dir="${module}/src" + if [[ ! -d "${source_dir}" ]]; then + echo "ERROR: expected extension source directory is missing: ${source_dir}" >&2 + status=1 + continue + fi + if grep -rnE 'InternalStoreApi|org[.]mobilenativefoundation[.]store6[.]core[.]internal' "${source_dir}"; then + echo "ERROR: ${module} accesses store6-core internals" >&2 + status=1 + else + grep_status=$? + if [[ "${grep_status}" -ne 1 ]]; then + echo "ERROR: grep failed for ${source_dir} with status ${grep_status}" >&2 + status=1 + fi + fi + done + exit "${status}" + + - name: Verify seam package matches the TD-13 freeze list + shell: bash + run: | + set -euo pipefail + seam_dir="store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam" + expected=( + Bookkeeper.kt + Fetcher.kt + FetcherResult.kt + FreshnessValidator.kt + KeyEvents.kt + Overlay.kt + SourceOfTruth.kt + StoreResults.kt + StoreRuntime.kt + StoreTelemetry.kt + StoreWriteHandle.kt + TransactionalSourceOfTruth.kt + WallClock.kt + ) + diff -u \ + <(printf '%s\n' "${expected[@]}" | LC_ALL=C sort) \ + <(LC_ALL=C ls -1A "${seam_dir}" | LC_ALL=C sort) + + - name: Enforce the TD-8 primitive whitelist and single-writer residence + shell: bash + run: | + set -euo pipefail + status=0 + banned_regex='(^|[^[:alnum:]_])(runBlocking|GlobalScope|atomicfu|Channel|actor)([^[:alnum:]_]|$)|kotlinx[.]coroutines[.]channels[.][*]' + shopt -s nullglob + production_source_dirs=(store6-core/src/*Main store6-testing/src/*Main store6-sqldelight/src/*Main store6-compose/src/*Main store6-room/src/*Main store6-devtools/src/*Main store6-devtools-inspector/src/*Main store6-mutations/src/*Main store6-mutations-testing/src/*Main store6-mutations-sqldelight/src/*Main store6-paging-androidx/src/*Main store6-graphql/src/*Main store6-realtime/src/*Main) + for source_dir in "${production_source_dirs[@]}"; do + [[ -d "${source_dir}" ]] || continue + if grep -rnE --include='*.kt' "${banned_regex}" "${source_dir}"; then + echo "ERROR: banned concurrency primitive found in ${source_dir}" >&2 + status=1 + else + grep_status=$? + if [[ "${grep_status}" -ne 1 ]]; then + echo "ERROR: grep failed for ${source_dir} with status ${grep_status}" >&2 + status=1 + fi + fi + done + key_engine="store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEngine.kt" + python3 - "${key_engine}" <<'PY' || status=1 + import re + import sys + from pathlib import Path + + assignment = re.compile(r"\bresidence\s*\.\s*value\s*=(?!=)") + + + def mask_non_code(source: str) -> str: + output: list[str] = [] + state = "code" + block_depth = 0 + index = 0 + + def mask(text: str) -> None: + output.extend(char if char in "\r\n" else " " for char in text) + + while index < len(source): + if state == "code": + if source.startswith("//", index): + mask(source[index:index + 2]) + index += 2 + state = "line-comment" + elif source.startswith("/*", index): + mask(source[index:index + 2]) + index += 2 + block_depth = 1 + state = "block-comment" + elif source.startswith('"""', index): + mask(source[index:index + 3]) + index += 3 + state = "raw-string" + elif source[index] == '"': + mask(source[index]) + index += 1 + state = "string" + elif source[index] == "'": + mask(source[index]) + index += 1 + state = "char" + else: + output.append(source[index]) + index += 1 + elif state == "line-comment": + mask(source[index]) + if source[index] == "\n": + state = "code" + index += 1 + elif state == "block-comment": + if source.startswith("/*", index): + mask(source[index:index + 2]) + index += 2 + block_depth += 1 + elif source.startswith("*/", index): + mask(source[index:index + 2]) + index += 2 + block_depth -= 1 + if block_depth == 0: + state = "code" + else: + mask(source[index]) + index += 1 + elif state in ("string", "char"): + delimiter = '"' if state == "string" else "'" + if source[index] == "\\": + end = min(index + 2, len(source)) + mask(source[index:end]) + index = end + else: + closing = source[index] == delimiter + mask(source[index]) + index += 1 + if closing: + state = "code" + elif state == "raw-string": + if source.startswith('"""', index): + mask(source[index:index + 3]) + index += 3 + state = "code" + else: + mask(source[index]) + index += 1 + + if state not in ("code", "line-comment"): + raise ValueError(f"unterminated Kotlin lexical state: {state}") + return "".join(output) + + + source_path = Path(sys.argv[1]) + try: + source = source_path.read_text(encoding="utf-8") + raw_count = len(assignment.findall(source)) + code_count = len(assignment.findall(mask_non_code(source))) + except (OSError, UnicodeError, ValueError) as error: + print(f"ERROR: writer audit failed for {source_path}: {error}", file=sys.stderr) + raise SystemExit(1) + + if raw_count != 1 or code_count != 1: + print( + f"ERROR: expected exactly one raw and one code-level residence.value " + f"assignment in {source_path}; raw_count={raw_count}, code_count={code_count}", + file=sys.stderr, + ) + raise SystemExit(1) + print( + f"Verified residence.value assignment counts in {source_path}: " + f"raw_count={raw_count}, code_count={code_count}" + ) + PY + exit "${status}" + + - name: JS lock-discipline canary (full conformance suite on the JS lane) + run: ./gradlew :store6-core:jsNodeTest :store6-testing:jsNodeTest :store6-mutations:jsNodeTest :store6-mutations-testing:jsNodeTest :store6-paging-androidx:jsNodeTest :store6-graphql:jsNodeTest :store6-realtime:jsNodeTest --stacktrace + + - name: Upload test reports + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: test-reports-store6-linux-${{ github.run_id }}-${{ github.run_attempt }} + path: | + store6-*/build/test-results/**/*.xml + store6-*/build/reports/tests/** + if-no-files-found: warn + retention-days: 7 + + apple-tests: + runs-on: macos-latest + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Cache Kotlin/Native compiler + uses: actions/cache@v4 + with: + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }} + restore-keys: | + ${{ runner.os }}-konan- + + - name: Grant execute permission for Gradlew + run: chmod +x gradlew + + - name: Select an available iPhone simulator + id: ios_simulator + shell: bash + run: | + device_name="$( + xcrun simctl list devices available -j | + jq -r '[.devices[][] | select(.name | startswith("iPhone"))] | first | .name // empty' + )" + if [[ -z "${device_name}" ]]; then + echo "No available iPhone simulator was found." >&2 + exit 1 + fi + echo "Selected iPhone simulator: ${device_name}" + echo "device_name=${device_name}" >> "${GITHUB_OUTPUT}" + + - name: Run Store6 Apple tests + env: + STORE6_IOS_SIMULATOR_DEVICE: ${{ steps.ios_simulator.outputs.device_name }} + run: | + ./gradlew \ + :store6-core:iosSimulatorArm64Test \ + :store6-core:macosArm64Test \ + :store6-extension-probe:iosSimulatorArm64Test \ + :store6-extension-probe:macosArm64Test \ + :store6-testing:iosSimulatorArm64Test \ + :store6-testing:macosArm64Test \ + :store6-sqldelight:iosSimulatorArm64Test \ + :store6-sqldelight:macosArm64Test \ + :store6-compose:iosSimulatorArm64Test \ + :store6-compose:macosArm64Test \ + :store6-room:iosSimulatorArm64Test \ + :store6-room:macosArm64Test \ + :store6-paging-androidx:iosSimulatorArm64Test \ + :store6-paging-androidx:macosArm64Test \ + :store6-graphql:iosSimulatorArm64Test \ + :store6-graphql:macosArm64Test \ + :store6-realtime:iosSimulatorArm64Test \ + :store6-realtime:macosArm64Test \ + :store6-devtools:iosSimulatorArm64Test \ + :store6-devtools:macosArm64Test \ + :store6-devtools-inspector:iosSimulatorArm64Test \ + :store6-devtools-inspector:macosArm64Test \ + :store6-mutations:iosSimulatorArm64Test \ + :store6-mutations:macosArm64Test \ + :store6-mutations-testing:iosSimulatorArm64Test \ + :store6-mutations-testing:macosArm64Test \ + :store6-mutations-sqldelight:iosSimulatorArm64Test \ + :store6-mutations-sqldelight:macosArm64Test \ + "-Pstore6.iosSimulatorDevice=${STORE6_IOS_SIMULATOR_DEVICE}" \ + --stacktrace + + - name: Link Store6 devtools demo iOS framework + run: ./gradlew :store6-devtools-demo:linkDebugFrameworkIosSimulatorArm64 --stacktrace + + - name: Upload test reports + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: test-reports-store6-apple-${{ github.run_id }}-${{ github.run_attempt }} + path: | + store6-*/build/test-results/**/*.xml + store6-*/build/reports/tests/** + if-no-files-found: warn + retention-days: 7 + + swift-dumps: + runs-on: macos-latest + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Cache Kotlin/Native compiler + uses: actions/cache@v4 + with: + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }} + restore-keys: | + ${{ runner.os }}-konan- + + - name: Grant execute permission for Gradlew + run: chmod +x gradlew + + - name: Check committed Swift dumps + run: ./gradlew checkSwiftDumps --stacktrace + + - name: Upload Swift dump diagnostics + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: swift-dump-diagnostics-${{ github.run_id }}-${{ github.run_attempt }} + path: | + store6-swift-dumps/**/build/swift-dump/** + store6-swift-dumps/**/build/bin/iosArm64/debugFramework/**/*.h + store6-swift-dumps/**/build/skie/** + store6-core/api/swift/** + store6-mutations/api/swift/** + if-no-files-found: warn + retention-days: 7 + + klib-publication-check: + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Cache Kotlin/Native compiler + uses: actions/cache@v4 + with: + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }} + restore-keys: | + ${{ runner.os }}-konan- + + - name: Grant execute permission for Gradlew + run: chmod +x gradlew + + - name: Publish Store6 core to Maven local without signing + run: > + ./gradlew :store6-core:publishToMavenLocal :store6-testing:publishToMavenLocal :store6-sqldelight:publishToMavenLocal :store6-compose:publishToMavenLocal :store6-room:publishToMavenLocal :store6-devtools:publishToMavenLocal :store6-devtools-inspector:publishToMavenLocal :store6-mutations:publishToMavenLocal :store6-mutations-testing:publishToMavenLocal :store6-mutations-sqldelight:publishToMavenLocal :store6-paging-androidx:publishToMavenLocal :store6-graphql:publishToMavenLocal :store6-realtime:publishToMavenLocal + -Pkotlin.native.enableKlibsCrossCompilation=true + -Pkotlin.apple.xcodeCompatibility.nowarn=true + --stacktrace + + - name: Verify common and target publications + shell: bash + run: | + group_id="org.mobilenativefoundation.store" + version="6.0.0-SNAPSHOT" + repository="${HOME}/.m2/repository/org/mobilenativefoundation/store" + + modules=(store6-core store6-testing store6-sqldelight store6-compose store6-room store6-devtools store6-devtools-inspector store6-mutations store6-mutations-testing store6-mutations-sqldelight store6-paging-androidx store6-graphql store6-realtime) + suffixes=( + "" + -android + -iosarm64 + -iossimulatorarm64 + -iosx64 + -js + -jvm + -linuxx64 + -macosarm64 + -mingwx64 + -tvosarm64 + -wasm-js + -watchosarm64 + ) + + failed=0 + for module in "${modules[@]}"; do + for suffix in "${suffixes[@]}"; do + case "${module}:${suffix}" in + store6-paging-androidx:-iosx64) + # store6-paging-androidx ships the paging-common-3.5.1 target subset; androidx.paging + # publishes no Intel artifacts since 3.4.0-rc01. + continue + ;; + store6-room:-js|store6-room:-wasm-js|store6-room:-mingwx64|store6-room:-iosx64) + # store6-room ships Room 3's target subset; room3 publishes no iosX64 and no + # web/mingw klibs for this module's scope. + continue + ;; + store6-devtools-inspector:-watchosarm64|store6-devtools-inspector:-tvosarm64|store6-devtools-inspector:-linuxx64|store6-devtools-inspector:-mingwx64) + # store6-devtools-inspector ships the CMP-UI subset; Compose foundation/material3 + # publish no watchOS/tvOS/Linux/MinGW variants. + continue + ;; + esac + artifact_id="${module}${suffix}" + artifact_dir="${repository}/${artifact_id}/${version}" + case "${suffix}" in + ""|-jvm) + extension="jar" + ;; + -android) + extension="aar" + ;; + *) + extension="klib" + ;; + esac + artifact_file="${artifact_dir}/${artifact_id}-${version}.${extension}" + if [[ ! -f "${artifact_file}" ]]; then + echo "Missing consumable publication artifact: ${artifact_file}" >&2 + failed=1 + continue + fi + echo "Verified ${group_id}:${artifact_id}:${version} (${extension})" + done + done + exit "${failed}" + + native-stress: + runs-on: macos-latest + timeout-minutes: 40 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Cache Kotlin/Native compiler + uses: actions/cache@v4 + with: + path: ~/.konan + key: ${{ runner.os }}-konan-${{ hashFiles('gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties', '**/*.gradle.kts') }} + restore-keys: | + ${{ runner.os }}-konan- + + - name: Grant execute permission for Gradlew + run: chmod +x gradlew + + - name: Run Store6 Native stress and soak lane (macOS) + shell: bash + run: | + set -euo pipefail + ./gradlew :store6-core:macosArm64Test \ + --tests '*StoreEvictionStressTest' \ + --tests '*StoreInvalidationStressTest' \ + --tests '*StoreCloseLifecycleTest' \ + --tests '*StoreBackpressureConformanceTest' \ + --stacktrace + + result_dir="store6-core/build/test-results/macosArm64Test" + expected_test_classes=( + StoreEvictionStressTest + StoreInvalidationStressTest + StoreCloseLifecycleTest + StoreBackpressureConformanceTest + ) + python3 - "${result_dir}" "${expected_test_classes[@]}" <<'PY' + import sys + from pathlib import Path + import xml.etree.ElementTree as ET + + + def local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + + result_dir = Path(sys.argv[1]) + expected_test_classes = sys.argv[2:] + result_files = sorted(result_dir.glob("TEST-*.xml")) + if not result_files: + print(f"ERROR: no Kotlin/Native test-result XML found in {result_dir}", file=sys.stderr) + raise SystemExit(1) + + executed = {test_class: 0 for test_class in expected_test_classes} + for result_file in result_files: + try: + root = ET.parse(result_file).getroot() + except (OSError, ET.ParseError) as error: + print(f"ERROR: failed to parse {result_file}: {error}", file=sys.stderr) + raise SystemExit(1) + + for testcase in root.iter(): + if local_name(testcase.tag) != "testcase": + continue + if any(local_name(child.tag) == "skipped" for child in testcase): + continue + classname = testcase.get("classname", "") + for test_class in expected_test_classes: + if classname == test_class or classname.endswith(f".{test_class}"): + executed[test_class] += 1 + + missing = [test_class for test_class, count in executed.items() if count == 0] + for test_class, count in executed.items(): + if count: + print(f"Verified {count} executed Native testcase(s) for {test_class}") + if missing: + print( + "ERROR: no non-skipped Native testcase evidence for: " + ", ".join(missing), + file=sys.stderr, + ) + raise SystemExit(1) + PY + + - name: Upload test reports + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: test-reports-store6-native-stress-${{ github.run_id }}-${{ github.run_attempt }} + path: | + store6-*/build/test-results/**/*.xml + store6-*/build/reports/tests/** + if-no-files-found: warn + retention-days: 7 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..a3b24b80b --- /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; composes with the discipline skill). Agents with skill support invoke them by +name before documentation work; agents without it read the `SKILL.md` files and their +`references/` directly. The core rules below are the load-bearing summary and apply either +way. + +### The master test + +Keep a documentation element only when the reader needs it to use, change, operate, or +reason about the documented system correctly. Cut throat-clearing, hype, vague benefits, +decorative language, narration of visible syntax or control flow, and invented precision. + +### Protected technical content + +A wording pass never alters identifiers, signatures, commands, paths, URLs, versions, +numbers, units, measurements, schema fields, error names, compatibility statements, +behavioral guarantees, or evidence classifications. Semantic directives and behavior-bearing +comments (suppressions, build constraints, generator markers, tool configuration) are +protected even though they are syntactically comments. If a requested change requires +altering a protected token, stop and report it as a technical change needing its own +authorization — do not fold it into a style pass silently. + +### No internal organizational context in code surfaces + +Code documentation must be self-contained and durable. Do not put issue-tracker IDs, +internal project or initiative names, ruling or approval shorthand, landing status, team +shorthand, or internal revision labels into source comments, workflow comments, or step +names. State the technical fact with a durable attribution instead — for example +"measured 2h40m-3h13m on hosted runners at the current suite" rather than a tracker +reference. Pull-request bodies and commit messages may reference issues and process records; +source files may not. + +### Evidence before claims + +State confirmed facts. Label uncertainty explicitly. Verify a claim before writing it — a +coverage or completeness claim ("every X now does Y") requires an actual sweep, not an +extrapolation from the files you happened to touch. Never present a failed command or +example as working. + +### Repository comment conventions + +Some comment blocks are deliberately byte-identical across sibling files (for example the +test-deadline wrapper comment that appears with the shared `runTest` shim in test files). +Match the established shape exactly when extending such a pattern; do not reword one copy. +When editing near an existing comment, preserve it unless it is wrong — revise only the +evidenced deficiency. + +### Three-pass review + +Before finishing documentation work, run three separate passes: + +1. **Accuracy** — every protected token, contract, and classification unchanged and correct + against the source. +2. **Warrant** — every remaining claim supported by evidence or labeled as uncertain. +3. **Reader utility** — the intended reader can complete their task without missing + prerequisites, boundaries, units, risks, or operational consequences. diff --git a/README.md b/README.md index f67ae8b45..31a632549 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,34 @@ [![codecov](https://codecov.io/gh/MobileNativeFoundation/Store/branch/main/graph/badge.svg?token=0UCmG3QHPf)](https://codecov.io/gh/MobileNativeFoundation/Store) +## Store 6 + +Store 6 is the next major line, published under `store6-*` coordinates alongside Store 5 for the +whole 6.x major. It is a Kotlin Multiplatform library for reading and writing data that lives in +more than one place: a network, a local database, and memory. You describe a key and a fetcher, and +Store handles single-flighting concurrent demand, staleness, invalidation, and bounded memory, with +every zero-config behavior named and covered by a conformance test you can read. + +**Status: in development, targeting 6.0.0-alpha01.** Nothing is published yet. + +Two things about the first alpha, stated up front rather than discovered later: + +- **Mutations ship experimental.** `store6-mutations` is a separate artifact and every public symbol + is `@ExperimentalStoreApi`. The tier is on the artifact, never annotation-gated inside a stable + one. +- **Mutations ship the two-step durable ack posture.** The non-transactional acknowledgement path + adopts the server echo first and retires the journal row last, so a crash inside that window + leaves a replayable pending intent rather than losing the write. The consequence is that the same + push can be re-sent after such a crash, so design those endpoints to be idempotent. Making the ack + path atomic is beta01 work, not alpha01 work. + +The full policy — API tiers, the deprecation cycle, the cadence commitment, and how you can verify +all of it from a released tag — is in [STABILITY.md](./STABILITY.md). The public roadmap is at +[ROADMAP.md](./ROADMAP.md), and the quickstart is at +[docs/store6/quickstart.md](./docs/store6/quickstart.md). + +--- + #### Documentation Comprehensive guides, tutorials, and API reference: [store.mobilenativefoundation.org](https://store.mobilenativefoundation.org). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..0f79e8e72 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,113 @@ +# Store 6 roadmap + +Store 6's plan, with dates on it. Some of those dates will move. What will not move is the rule +that governs how they move, stated in the first section below. Where a window is an estimate, this +page says so and gives the range. + +This is the roadmap [#534][534] asked for. + +## Operating principles + +These are commitments, not aspirations. + +1. **Cut scope, never cadence.** A slip threatens a release's contents, never its date. +2. **The read core never waits on an extension.** If paging, the Swift facade, or Store 5 interop + slips, 6.0 still ships as a complete, stable read library without it. Mutations are not an + extension for this rule's purposes — writing is functionality Store 5 already shipped, and Store 6 + is not publishable to this community without it. It lives in a separate artifact because its API + is experimental, which is a packaging decision, not a dependency one. +3. **Experimental code lives in separate artifacts.** Never annotation-gated inside a stable + artifact, so a tier is always visible on the thing you depend on. +4. **Docs are launch gates, not follow-ups.** A release without its documentation is not done, and + migration guides ship with the migration. +5. **Gates are written down before the work starts**, so a feature ships when its criteria pass + rather than when enthusiasm peaks. + +## Release train + +### Foundation (Q3–Q4 2026) + +The build, the target matrix, the CI lanes, and the API-review discipline, proven end to end before +depth is added. Binary-compatibility and generated-Swift dumps gated in CI from the first alpha. +Store 6 is developed in a fork and lands in this repository under `store6.*` before the alpha01 cut. +History, stars, and watchers stay here. + +### 6.0.0-alpha01 — target Q4 2026 (confidence range Q4 2026 – Q1 2027) + +The list is split into a floor that defines the release and deliverables that may slip a month +under principle 1. The confidence range above is real: treat Q1 2027 as the honest outer bound. + +**The floor — these are alpha01:** + +| | | +|---|---| +| `store6-core`, `store6-testing` | The engine and its conformance kit. | +| `store6-mutations` | The write path: journal, drain, rebase, conflict stack, restart replay. Experimental artifact, in the floor rather than the may-slip list. | +| STABILITY.md + this roadmap | The published policy: tiers, deprecation cycle, cadence commitment. | +| Quickstart + Important Defaults | The mental model before the API reference. | + +**May slip one alpha:** the SQLDelight, Room, and Compose adapters, the devtools MVP, and the +remaining documentation pages. Anything that slips gets its target alpha named in the release notes. + +### Mutations beta train + 6.0.0-beta01 (Q1–Q2 2027) + +Ack-path atomicity and its crash matrix, the Paging 3 interop adapter, the Swift SPM facade against +the freeze-candidate core, the outbox inspector demo, and Store 5 interop with migration lint. + +beta01 is the **core API freeze candidate**. From beta01 forward, no source-breaking core change +without an RC reset. + +A word on what "freeze" means here, because it is the promise most worth being precise about. The +seam you implement to plug in your own fetcher, source of truth, bookkeeper, clock, telemetry, or +overlay becomes a freeze **candidate** once a real producer has exercised it end to end, which the +mutations work does before alpha01. It becomes **frozen** only after the ack-path atomicity work and +its test matrix are green. If that work misses beta01, the overlay and write-handle surfaces ship +experimental outside the frozen tier and the rest of the core freezes on schedule. Two stages, both +stated, neither skipped. + +### 6.0.0 GA — target Q3 2027 (confidence range Q3 – Q4 2027) + +Core, testing, the adapters, Store 5 interop, and the BOM in the stable tier, the adapters having +run the contract kit throughout the alpha line. Paging ships alongside as a supported experimental +artifact with the tier on the tin. The 5→6 and "Store 4 → 6 in an afternoon" migration guides both +block GA. Store 5 moves to fixes-only maintenance with a dated end-of-life published at GA. + +### After GA + +6.1 brings the first mutations graduation review. 6.2 is gated rather than dated. 6.3 is the target +window for mutations graduation to stable. + +## Cadence + +**Monthly alphas from 6.0.0-alpha01.** Each release names the next release's target month, and each +one closes at least one community issue with a link to the named guarantee that resolves it — a +conformance test, not a changelog line. + +Full policy, including the deprecation cycle and how to verify any of this from a released tag, is +in [STABILITY.md](./STABILITY.md). + +## Mutations graduation + +Mutations stay experimental past GA. The first review is at 6.1, and the target window for +graduation is roughly 6.3. Graduation requires the API unchanged across two consecutive minors, +crash-matrix and soak lanes green in production-representative apps, and at least three external +production adopters reporting. If those are not met, it stays experimental and the review repeats. +There is no date-driven graduation. + +## How to contribute + +- **Documentation.** Every page in this line names the source it was written from, and code blocks + come from modules CI compiles. If a page loses you, open an issue saying where. That is a useful + bug report, and it is the one we most want. +- **Semantics.** The conformance suite under + [`store6-core/src/commonTest`](store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/) + is the specification. If you can describe a behavior you expected and a test that would have + caught it, that is a complete contribution before a line of implementation. +- **Adapters and platforms.** The source-of-truth seam is small on purpose. An adapter for a store + we do not cover is a self-contained contribution. +- **Where to talk.** The [#store](https://kotlinlang.slack.com/archives/C06007Z01HU) channel on + Kotlin Slack, or an issue on this repository. + +Issues that name a concrete expectation get answered with a test. That is the on-ramp. + +[534]: https://github.com/MobileNativeFoundation/Store/issues/534 diff --git a/STABILITY.md b/STABILITY.md new file mode 100644 index 000000000..8bdfe23b2 --- /dev/null +++ b/STABILITY.md @@ -0,0 +1,209 @@ +# Store 6 stability policy + +## 1. What this document is + +What each `store6-*` artifact promises, how an API is allowed to change, how often we ship, and how +you can verify all of it from a released tag. Where a promise is not yet earned, this document says +so rather than rounding up. + +It is also the standing answer to [#570][570] on binary compatibility and [#534][534] on a published +roadmap. + +Scope: the `store6-*` artifacts, effective with the 6.0.0-alpha01 release. Store 5 continues under +its own coordinates, and [§6](#6-migrating-from-store-5) covers living with both. + +## 2. API tiers + + + +Store 6 uses three opt-in markers. Each is a real annotation in `store6-core`, and the meaning below +is the one carried in its own KDoc. + +| Marker | Means | +|---|---| +| `@ExperimentalStoreApi` | API under active development that **may change or be removed in any release**. Experimental API ships in separate artifacts wherever possible; the marker exists for the cases where an experimental member must live beside stable API. | +| `@DelicateStoreApi` | API that is **stable but easy to misuse** — for example implementing `Store` directly instead of building one through the `store { }` DSL. Opting in asserts that you uphold the documented contract of the marked declaration. | +| `@InternalStoreApi` | API **internal to the Store libraries**. It may change or disappear without notice even in patch releases, and must never be used outside `org.mobilenativefoundation.store` artifacts. | + +All three are `RequiresOptIn.Level.ERROR`: you cannot use them by accident. `Store` additionally +carries `@SubclassOptInRequired(DelicateStoreApi::class)`, so implementing the interface yourself is +a deliberate act, not a default. + +**Experimental code lives in separate artifacts, never annotation-gated inside a stable one.** When +a capability needs its own release rhythm, it gets its own artifact, and the tier is stated on the +artifact rather than buried in an annotation on a member you have already depended on. + +**SemVer is scoped to the stable tier.** A breaking change to an `@ExperimentalStoreApi` surface in +a minor release is not a SemVer violation, because that surface never claimed the guarantee. That is +the whole point of stating the tier on the tin. + +## 3. Artifacts and tiers, as of 6.0.0-alpha01 + +Group coordinates are unchanged: `org.mobilenativefoundation.store`. Packages are +`org.mobilenativefoundation.store6.*`. + +| Artifact | Tier | In 6.0.0-alpha01 | +|---|---|---| +| `store6-core` | Stable-track. The API is **not frozen** until the beta01 freeze candidate. | alpha01 | +| `store6-testing` | Experimental (`@ExperimentalStoreApi`) — every public declaration in the artifact carries the marker today. | alpha01 | +| `store6-sqldelight` | Experimental adapter (`@ExperimentalStoreApi`). Graduates to stable at 6.0.0, having run the contract kit throughout the alpha line. | alpha01, may slip one alpha | +| `store6-room` | Experimental adapter, same graduation. | alpha01, may slip one alpha | +| `store6-compose` | Experimental adapter, same graduation. | alpha01, may slip one alpha | +| `store6-mutations` | **Experimental, separate artifact — every public symbol is `@ExperimentalStoreApi`.** See [§8](#mutations). | alpha01 | +| `store6-bom` | Version alignment only; no API surface of its own. | alpha01 | +| `store6-devtools` | Experimental (`@ExperimentalStoreApi`). | alpha02 (target) | +| `store6-devtools-inspector` | Experimental (`@ExperimentalStoreApi`). | alpha02 (target) | + +Inside `store6-core`, the `org.mobilenativefoundation.store6.core.seam` package — the 13 files you +implement to plug in your own fetcher, source of truth, bookkeeper, clock, telemetry, or overlay — +is a **freeze candidate, not frozen.** Today these types are `@ExperimentalStoreApi`, so +implementing one is an explicit opt-in; that is the exception §2 names, and it is why the seam sits +inside a stable-track artifact rather than shipping separately. + +The candidate-versus-frozen distinction is load-bearing and we state it in two stages deliberately. +A real producer has to exercise a seam end to end before we will call it a candidate. The +`Overlay` and `StoreWriteHandle` surfaces become frozen only once the ack-path atomicity work and +its test matrix are green; if that work misses beta01, those two ship `@ExperimentalStoreApi` +outside the frozen tier and the rest of core freezes on schedule. CI enforces the 13-file list on +every pull request, so the seam cannot grow quietly. + +Promised: `store6-store5-interop`, tracking to 6.0.0 and not in the alpha01 line, and +`store6-paging-androidx`, which joins the line in the first release it is green for. An artifact +that misses a train gets its target release named here. It does not get dropped silently. + +## 4. Deprecation cycle + + + +Every removal from the stable tier goes through three stages: + +1. **`WARNING` with `ReplaceWith`.** The replacement is mechanical wherever the shape allows it. +2. **`ERROR`, no earlier than two minor releases later.** You get at least two minors of warning + before your build breaks. +3. **`HIDDEN` at the next major.** Binary compatibility is preserved until then. + +**No silent capability drops.** A removed capability gets the same cycle and a migration note. You +should never find out a capability is gone by upgrading. + +## 5. Release cadence + + + +**Monthly alphas from 6.0.0-alpha01.** The governing rule is **cut scope, never cadence**: a slip +threatens a release's contents, never its date. If something is not ready, it ships in the next +alpha a month later and the release notes say so. + +We will not repeat a 30-month alpha line, and we will not break API in beta again. + +Each alpha closes at least one community issue with a link to the named guarantee that resolves it — +a conformance test, not a changelog line. The next alpha's target month is stated in each release's +notes. This document states the policy. Each release states the date. + +The public roadmap is at [ROADMAP.md](./ROADMAP.md). + +## 6. Migrating from Store 5 + +`store5.*` and `store6.*` coordinates live **side by side for the whole 6.x major**. You can depend +on both in one build and migrate a screen at a time. There is no flag day. + +`store6-store5-interop` is supported for all of 6.x. The 5→6 and 4→6 migration guides are launch +gates for 6.0.0 — they block GA, they are not follow-ups. + +## 7. How stability is verified + + + +Every claim in this document is checkable from a released tag. + +- **`explicitApi()` strict** on every `store6-*` library module. Nothing becomes public by omission. +- **Binary-compatibility-validator (0.17.0) with klib validation enabled.** Each module commits a + JVM `.api` dump and a `.klib.api` dump — for example `store6-core/api/jvm/store6-core.api` and + `store6-core/api/store6-core.klib.api`. The check runs as part of `build` on every pull request, + so an unintended ABI change fails CI before review. +- **Generated-Swift dumps diffed on every pull request** across the supported bridges — Obj-C export + and SKIE today (`store6-core/api/swift/objc`, `store6-core/api/swift/skie`). The bridge set follows + the Swift Export disposition recorded at the alpha01 cut, so read this as a commitment to the + mechanism rather than to a fixed list of lanes. +- **ABI dumps are committed at every released tag**, so the surface of any release is diffable from + the repository without resolving artifacts. +- **The conformance suite is public documentation of what is guaranteed.** The behaviors this + library promises are named tests you can read: + [`store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/`](store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/) + (`*ConformanceTest.kt`). When a release closes one of your issues, the notes link the test, not a + bullet point. + +## 8. Mutations at 6.0.0-alpha01 + + + +`store6-mutations` is in the alpha01 floor, not the may-slip list: an app that writes should not +have to wait for a later alpha. Three things about it are worth stating plainly. + +### (a) The tier + +Experimental, in its own artifact, every public symbol `@ExperimentalStoreApi`. The written +graduation criteria are published alongside the 6.0.0-alpha01 release and linked from this section +then; the first review is at 6.1. The target window for graduation to stable is roughly 6.3, and it +is a target rather than a schedule: graduation requires the API unchanged across two consecutive +minors, +crash-matrix and soak lanes green in production-representative apps, and at least three external +production adopters reporting. If those are not met, it stays experimental and the review repeats. +Nothing graduates because a date arrived. + +### (b) The durable acknowledgement posture + +Every public mutation store has a journal storage, including the in-memory default, which does not +survive process restart. After the server returns an acknowledgement, Store records the receipt, any +pending alias or tombstone, and the `ACKED` execution phase in one journal transaction. Only after +it commits does Store adopt the result, apply effects, and finalize retirement. + +If the server accepts a push before the local acknowledgement commits, the intent remains `INFLIGHT`; +a later drain can resend the same immutable generation and key. Durable storage preserves that replay. +This is the same conservative crash-window stance used for reads: prefer doing work twice over +losing it. + +Once `ACKED` is committed, recovery resumes adoption, effects, and retirement without calling +`MutationServer.push` again for that generation. Those post-acknowledgement steps may repeat +conservatively after a failure, but the accepted write is not sent twice from that durable phase. + +The consequence: design mutation endpoints to treat a repeated idempotency key as the same request. +This covers the remote-acceptance window before the local acknowledgement transaction commits. + +### (c) The surface has been reviewed — and stays experimental + +The mutations API review ran and ruled the surface (twenty rulings, 2026-08-01): the entry point +is the required-input `mutationStore` factory with an overlay-free builder, restart-safe key +recovery is a compile-time-required resolver, the value state is an explicit presence algebra, +and the persistence a caller installs is retained for the transactional ack-path decorator. +The module remains experimental — shapes can change in any release, and this document still +deliberately freezes no mutations signature into policy prose. + +## 9. Reading pending writes and staleness + +Two affordances that look similar are not, and getting them backwards produces UI bugs that are +hard to trace. + +- **A "pending write" affordance keys on `origin == OVERLAY`.** +- **A "stale cache" affordance keys on `isStale`.** + +`isStale` is **never set on an `OVERLAY` frame.** Overlay frames are fresh by definition: they are +stamped `age = Duration.ZERO` and `isStale = false` unconditionally, because an optimistic value +genuinely is new — the user just wrote it. On an overlay frame, only `refreshing` is live. So a +spinner driven by `isStale` will never fire for a pending write, and that is intended. Drive the +pending-write indicator off the origin and narrate the `OVERLAY` → `SOT` flip. + +**`Store.get` is unprojected.** Overlays apply only to `stream`, so an optimistic mutation is +invisible to `get`. This is a documented consequence of the read contract, not a defect: `get` is a +point read of committed truth. If you need to observe your own optimistic write, observe `stream`. + +## 10. Kotlin floor + +The `store6` line requires **Kotlin 2.3**, raised only in minor releases and with notice. + +The floor is what the published artifacts actually imply, not an aspiration: every published +`store6-core` variant — JVM, Android, JS, wasmJs, and each native target — declares +`org.jetbrains.kotlin:kotlin-stdlib:2.3.20`, and the build sets no `apiVersion` or `languageVersion` +compatibility pin that would lower it. Room 3 is what drove the toolchain here. + +[570]: https://github.com/MobileNativeFoundation/Store/issues/570 +[534]: https://github.com/MobileNativeFoundation/Store/issues/534 diff --git a/build.gradle.kts b/build.gradle.kts index b44c87ae5..e0e902816 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,39 +1,106 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +@file:OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) plugins { - alias(libs.plugins.android.kotlin.multiplatform) apply false - alias(libs.plugins.android.library) apply false - alias(libs.plugins.kotlin.multiplatform) apply false - alias(libs.plugins.kotlin.serialization) apply false - alias(libs.plugins.dokka) apply false - alias(libs.plugins.vanniktech.maven.publish) apply false - alias(libs.plugins.atomicfu) apply false - alias(libs.plugins.kotlin.cocoapods) apply false alias(libs.plugins.ktlint) - alias(libs.plugins.spotless) - alias(libs.plugins.binary.compatibility.validator) apply false - alias(libs.plugins.kmmbridge.github) apply false + id("com.diffplug.spotless") version "6.4.1" + // 010/011/012 toolchain: loaded once here (apply false) so every module shares one + // plugin classloader + version; module branches only `alias(...)` without versions. + alias(libs.plugins.ksp) apply false + alias(libs.plugins.sqldelight) apply false + alias(libs.plugins.room3) apply false + alias(libs.plugins.kotlin.compose.compiler) apply false + alias(libs.plugins.jetbrains.compose) apply false + alias(libs.plugins.kotlinx.benchmark) apply false } -tasks { - withType { - compilerOptions { - jvmTarget = JvmTarget.fromTarget(libs.versions.jvmCompat.get()) +buildscript { + repositories { + mavenCentral() + gradlePluginPortal() + google() + } + + dependencies { + classpath(libs.android.gradle.plugin) + classpath(libs.kotlin.gradle.plugin) + classpath(libs.kotlin.serialization.plugin) + classpath(libs.dokka.gradle.plugin) + classpath(libs.ktlint.gradle.plugin) + classpath(libs.jacoco.gradle.plugin) + classpath(libs.maven.publish.plugin) + classpath(libs.atomic.fu.gradle.plugin) + classpath(libs.kmmBridge.gradle.plugin) + classpath(libs.binary.compatibility.validator) + } +} + +allprojects { + repositories { + mavenCentral() + google() + } +} + +subprojects { + tasks.withType().configureEach { + compilerOptions.jvmDefault.set(org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode.DISABLE) + } + + pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") { + extensions.configure { + targets.withType().configureEach { + binaries.withType().configureEach { + // Kotlin 2.2.20+ exports KDoc by default; preserve the frozen Swift dumps. + exportKdoc.set(false) + } + } + } + } + + if (name.startsWith("store6")) return@subprojects + + apply(plugin = "org.jlleitschuh.gradle.ktlint") + apply(plugin = "com.diffplug.spotless") + + ktlint { + disabledRules.add("import-ordering") + } + + spotless { + kotlin { + target("src/**/*.kt") } } +} + +tasks { + withType { + compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } withType().configureEach { - sourceCompatibility = libs.versions.jvmCompat.get() - targetCompatibility = libs.versions.jvmCompat.get() + sourceCompatibility = JavaVersion.VERSION_11.name + targetCompatibility = JavaVersion.VERSION_11.name } } // Workaround for https://youtrack.jetbrains.com/issue/KT-62040 tasks.getByName("wrapper") -tasks.named("updateDaemonJvm") { - // JDK 17 is the minimum version supported by the org.gradle.toolchains.foojay-resolver-convention plugin - languageVersion = JavaLanguageVersion.of(17) - vendor.set(JvmVendorSpec.AZUL) +tasks.register("refreshSwiftDumps") { + dependsOn( + ":store6-swift-dumps-objc:refreshSwiftDump", + ":store6-swift-dumps-skie:refreshSwiftDump", + ":store6-swift-dumps-mutations-objc:refreshSwiftDump", + ":store6-swift-dumps-mutations-skie:refreshSwiftDump", + ) +} + +tasks.register("checkSwiftDumps") { + dependsOn( + ":store6-swift-dumps-objc:checkSwiftDump", + ":store6-swift-dumps-skie:checkSwiftDump", + ":store6-swift-dumps-mutations-objc:checkSwiftDump", + ":store6-swift-dumps-mutations-skie:checkSwiftDump", + ) } diff --git a/cache/README.md b/cache/README.md deleted file mode 100644 index e994c7589..000000000 --- a/cache/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# Cache - -Store depends on a subset of [Guava](https://github.com/google/guava). -This is a shaded artifact that is Kotlin Multiplatform compatible. - -## Usage - -```kotlin -implementation("org.mobilenativefoundation.store:cache:${STORE_VERSION}") -``` - -## Implementation - -### Model the key - -```kotlin -data class Key( - val id: String -) -``` - -### Model the value - -```kotlin -data class Post( - val title: String -) -``` - -### Build the cache - -```kotlin - val cache = CacheBuilder() - .maximumSize(100) - .expireAfterWrite(1.day) - .build() -``` - -## See Also - -https://github.com/google/guava/wiki/CachesExplained - -## License - -```text -Copyright (c) 2017 The New York Times Company - -Copyright (c) 2010 The Guava Authors - -Licensed under the Apache License, Version 2.0 (the "License"); you may not use this library except in -compliance with the License. You may obtain a copy of the License at - -www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an -"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific -language governing permissions and limitations under the License. -``` diff --git a/cache/api/jvm/cache.api b/cache/api/jvm/cache.api deleted file mode 100644 index 76111af4c..000000000 --- a/cache/api/jvm/cache.api +++ /dev/null @@ -1,69 +0,0 @@ -public abstract interface class org/mobilenativefoundation/store/cache5/Cache { - public fun getAllPresent ()Ljava/util/Map; - public abstract fun getAllPresent (Ljava/util/List;)Ljava/util/Map; - public abstract fun getIfPresent (Ljava/lang/Object;)Ljava/lang/Object; - public abstract fun getOrPut (Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; - public abstract fun invalidate (Ljava/lang/Object;)V - public abstract fun invalidateAll ()V - public abstract fun invalidateAll (Ljava/util/List;)V - public abstract fun put (Ljava/lang/Object;Ljava/lang/Object;)V - public abstract fun putAll (Ljava/util/Map;)V - public abstract fun size ()J -} - -public final class org/mobilenativefoundation/store/cache5/Cache$DefaultImpls { - public static fun getAllPresent (Lorg/mobilenativefoundation/store/cache5/Cache;)Ljava/util/Map; -} - -public final class org/mobilenativefoundation/store/cache5/CacheBuilder { - public static final field Companion Lorg/mobilenativefoundation/store/cache5/CacheBuilder$Companion; - public fun ()V - public final fun build ()Lorg/mobilenativefoundation/store/cache5/Cache; - public final fun concurrencyLevel (Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; - public final fun expireAfterAccess-LRDsOJo (J)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; - public final fun expireAfterWrite-LRDsOJo (J)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; - public final fun maximumSize (J)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; - public final fun ticker (Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; - public final fun weigher (JLkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store/cache5/CacheBuilder; -} - -public final class org/mobilenativefoundation/store/cache5/CacheBuilder$Companion { -} - -public final class org/mobilenativefoundation/store/cache5/StoreMultiCache : org/mobilenativefoundation/store/cache5/Cache { - public static final field Companion Lorg/mobilenativefoundation/store/cache5/StoreMultiCache$Companion; - public fun (Lorg/mobilenativefoundation/store/core5/KeyProvider;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/cache5/Cache;)V - public synthetic fun (Lorg/mobilenativefoundation/store/core5/KeyProvider;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/cache5/Cache;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun getAllPresent ()Ljava/util/Map; - public fun getAllPresent (Ljava/util/List;)Ljava/util/Map; - public synthetic fun getIfPresent (Ljava/lang/Object;)Ljava/lang/Object; - public fun getIfPresent (Lorg/mobilenativefoundation/store/core5/StoreKey;)Lorg/mobilenativefoundation/store/core5/StoreData; - public synthetic fun getOrPut (Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; - public fun getOrPut (Lorg/mobilenativefoundation/store/core5/StoreKey;Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/core5/StoreData; - public synthetic fun invalidate (Ljava/lang/Object;)V - public fun invalidate (Lorg/mobilenativefoundation/store/core5/StoreKey;)V - public fun invalidateAll ()V - public fun invalidateAll (Ljava/util/List;)V - public synthetic fun put (Ljava/lang/Object;Ljava/lang/Object;)V - public fun put (Lorg/mobilenativefoundation/store/core5/StoreKey;Lorg/mobilenativefoundation/store/core5/StoreData;)V - public fun putAll (Ljava/util/Map;)V - public fun size ()J -} - -public final class org/mobilenativefoundation/store/cache5/StoreMultiCache$Companion { - public final fun invalidKeyErrorMessage (Ljava/lang/Object;)Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor { - public fun (Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/cache5/Cache;)V - public final fun getAllPresent ()Ljava/util/Map; - public final fun getCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;)Lorg/mobilenativefoundation/store/core5/StoreData$Collection; - public final fun getSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;)Lorg/mobilenativefoundation/store/core5/StoreData$Single; - public final fun invalidateAll ()V - public final fun invalidateCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;)Z - public final fun invalidateSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;)Z - public final fun putCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;Lorg/mobilenativefoundation/store/core5/StoreData$Collection;)Z - public final fun putSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;Lorg/mobilenativefoundation/store/core5/StoreData$Single;)Z - public final fun size ()J -} - diff --git a/cache/build.gradle.kts b/cache/build.gradle.kts deleted file mode 100644 index 40c462fc0..000000000 --- a/cache/build.gradle.kts +++ /dev/null @@ -1,22 +0,0 @@ -plugins { - id("org.mobilenativefoundation.store.multiplatform") -} - -kotlin { - - sourceSets { - commonMain { - dependencies { - api(libs.kotlinx.atomic.fu) - api(projects.core) - implementation(libs.kotlinx.coroutines.core) - } - } - commonTest { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - } - } - } -} diff --git a/cache/config/ktlint/baseline.xml b/cache/config/ktlint/baseline.xml deleted file mode 100644 index 7d1ab2676..000000000 --- a/cache/config/ktlint/baseline.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/cache/gradle.properties b/cache/gradle.properties deleted file mode 100644 index ac546f2a1..000000000 --- a/cache/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -POM_NAME=org.mobilenativefoundation.store -POM_ARTIFACT_ID=cache5 -POM_PACKAGING=jar \ No newline at end of file diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Cache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Cache.kt deleted file mode 100644 index b2e89d042..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Cache.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -interface Cache { - /** - * @return [Value] associated with [key] or `null` if there is no cached value for [key]. - */ - fun getIfPresent(key: Key): Value? - - /** - * @return [Value] associated with [key], obtaining the value from [valueProducer] if necessary. - * No observable state associated with this cache is modified until loading completes. - * @param [valueProducer] Must not return `null`. It may either return a non-null value or throw an exception. - * @throws ExecutionExeption If a checked exception was thrown while loading the value. - * @throws UncheckedExecutionException If an unchecked exception was thrown while loading the value. - * @throws ExecutionError If an error was thrown while loading the value. - */ - fun getOrPut( - key: Key, - valueProducer: () -> Value, - ): Value - - /** - * @return Map of the [Value] associated with each [Key] in [keys]. Returned map only contains entries already present in the cache. - * The default implementation provided here throws a [NotImplementedError] to maintain backward compatibility for existing implementations. - */ - fun getAllPresent(keys: List<*>): Map - - /** - * @return Map of the [Value] associated with each [Key] in the cache. - */ - fun getAllPresent(): Map = throw NotImplementedError() - - /** - * Associates [value] with [key]. - * If the cache previously contained a value associated with [key], the old value is replaced by [value]. - * Prefer [getOrPut] when using the conventional "If cached, then return. Otherwise create, cache, and then return" pattern. - */ - fun put( - key: Key, - value: Value, - ) - - /** - * Copies all of the mappings from the specified map to the cache. The effect of this call is - * equivalent to that of calling [put] on this map once for each mapping from [Key] to [Value] in the specified map. - * The behavior of this operation is undefined if the specified map is modified while the operation is in progress. - */ - fun putAll(map: Map) - - /** - * Discards any cached value associated with [key]. - */ - fun invalidate(key: Key) - - /** - * Discards any cached value associated for [keys]. - */ - fun invalidateAll(keys: List) - - /** - * Discards all entries in the cache. - */ - fun invalidateAll() - - /** - * @return Approximate number of entries in the cache. - */ - fun size(): Long -} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/CacheBuilder.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/CacheBuilder.kt deleted file mode 100644 index 9a0782da5..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/CacheBuilder.kt +++ /dev/null @@ -1,79 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -import kotlin.time.Duration - -class CacheBuilder { - internal var concurrencyLevel = 4 - private set - internal val initialCapacity = 16 - internal var maximumSize = UNSET - private set - internal var maximumWeight = UNSET - private set - internal var expireAfterAccess: Duration = Duration.INFINITE - private set - internal var expireAfterWrite: Duration = Duration.INFINITE - private set - internal var weigher: Weigher? = null - private set - internal var ticker: Ticker? = null - private set - - fun concurrencyLevel(producer: () -> Int): CacheBuilder = - apply { - concurrencyLevel = producer.invoke() - } - - fun maximumSize(maximumSize: Long): CacheBuilder = - apply { - if (maximumSize < 0) { - throw IllegalArgumentException("Maximum size must be non-negative.") - } - this.maximumSize = maximumSize - } - - fun expireAfterAccess(duration: Duration): CacheBuilder = - apply { - if (duration.isNegative()) { - throw IllegalArgumentException("Duration must be non-negative.") - } - expireAfterAccess = duration - } - - fun expireAfterWrite(duration: Duration): CacheBuilder = - apply { - if (duration.isNegative()) { - throw IllegalArgumentException("Duration must be non-negative.") - } - expireAfterWrite = duration - } - - fun ticker(ticker: Ticker): CacheBuilder = - apply { - this.ticker = ticker - } - - fun weigher( - maximumWeight: Long, - weigher: Weigher, - ): CacheBuilder = - apply { - if (maximumWeight < 0) { - throw IllegalArgumentException("Maximum weight must be non-negative.") - } - - this.maximumWeight = maximumWeight - this.weigher = weigher - } - - fun build(): Cache { - if (maximumSize != -1L && weigher != null) { - throw IllegalStateException("Maximum size cannot be combined with weigher.") - } - return LocalCache.LocalManualCache(this) - } - - companion object { - private const val UNSET = -1L - } -} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/LocalCache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/LocalCache.kt deleted file mode 100644 index a71382a9f..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/LocalCache.kt +++ /dev/null @@ -1,2082 +0,0 @@ -/* - * Copyright (C) 2009 The Guava Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * KMP conversion - * Copyright (C) 2022 André Claßen - */ -package org.mobilenativefoundation.store.cache5 - -import kotlinx.atomicfu.AtomicArray -import kotlinx.atomicfu.AtomicRef -import kotlinx.atomicfu.atomic -import kotlinx.atomicfu.atomicArrayOfNulls -import kotlinx.atomicfu.locks.reentrantLock -import kotlinx.atomicfu.loop -import kotlin.math.min -import kotlin.time.Duration - -internal class LocalCache(builder: CacheBuilder) { - /** - * Mask value for indexing into segments. The upper bits of a key's hash code are used to choose - * the segment. - */ - private val segmentMask: Int - - /** - * Shift value for indexing within segments. Helps prevent entries that end up in the same segment - * from also ending up in the same bucket. - */ - private val segmentShift: Int - - /** - * The segments, each of which is a specialized hash table. - */ - - private val segments: Array?> - - /** - * Strategy for referencing values. - */ - private val valueStrength: Strength = Strength.Strong - - /** - * The maximum weight of this map. UNSET_LONG if there is no maximum. - */ - private val maxWeight: Long - - /** - * Weigher to weigh cache entries. - */ - private val weigher: Weigher - - /** - * How long after the last access to an entry the map will retain that entry. - */ - private val expireAfterAccessNanos: Long - - /** - * How long after the last write to an entry the map will retain that entry. - */ - private val expireAfterWriteNanos: Long - - /** - * Measures time in a testable way. - */ - private val ticker: Ticker - - /** - * Factory used to create new entries. - */ - private val entryFactory: EntryFactory - - private val evictsBySize: Boolean get() = maxWeight >= 0 - - private val customWeigher: Boolean get() = weigher !== OneWeigher - - private val expiresAfterWrite: Boolean get() = expireAfterWriteNanos > 0 - - private val expiresAfterAccess: Boolean get() = expireAfterAccessNanos > 0 - - private val usesAccessQueue: Boolean get() = expiresAfterAccess || evictsBySize - - private val usesWriteQueue: Boolean get() = expiresAfterWrite - - private val recordsWrite: Boolean get() = expiresAfterWrite - - private val recordsAccess: Boolean get() = expiresAfterAccess - - private val recordsTime: Boolean get() = recordsWrite || recordsAccess - - private val usesWriteEntries: Boolean get() = usesWriteQueue || recordsWrite - - private val usesAccessEntries: Boolean get() = usesAccessQueue || recordsAccess - - private sealed class Strength { - /* - * TODO(kevinb): If we strongly reference the value and aren't loading, we needn't wrap the - * value. This could save ~8 bytes per entry. - */ - object Strong : Strength() { - override fun referenceValue( - segment: Segment?, - entry: ReferenceEntry?, - value: V, - weight: Int, - ): ValueReference { - return if (weight == 1) { - StrongValueReference(value) - } else { - WeightedStrongValueReference( - value, - weight, - ) - } - } - } - - /** - * Creates a reference for the given value according to this value strength. - */ - abstract fun referenceValue( - segment: Segment?, - entry: ReferenceEntry?, - value: V, - weight: Int, - ): ValueReference - } - - /** - * Creates new entries. - */ - private sealed class EntryFactory { - object Strong : EntryFactory() { - override fun newEntry( - segment: Segment?, - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry { - return StrongEntry(key, hash, next) - } - } - - object StrongAccess : EntryFactory() { - override fun newEntry( - segment: Segment?, - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry { - return StrongAccessEntry(key, hash, next) - } - - override fun copyEntry( - segment: Segment?, - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry { - val newEntry = super.copyEntry(segment, original, newNext) - copyAccessEntry(original, newEntry) - return newEntry - } - } - - object StrongWrite : EntryFactory() { - override fun newEntry( - segment: Segment?, - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry { - return StrongWriteEntry(key, hash, next) - } - - override fun copyEntry( - segment: Segment?, - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry { - val newEntry = super.copyEntry(segment, original, newNext) - copyWriteEntry(original, newEntry) - return newEntry - } - } - - object StrongAccessWrite : EntryFactory() { - override fun newEntry( - segment: Segment?, - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry { - return StrongAccessWriteEntry(key, hash, next) - } - - override fun copyEntry( - segment: Segment?, - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry { - val newEntry = super.copyEntry(segment, original, newNext) - copyAccessEntry(original, newEntry) - copyWriteEntry(original, newEntry) - return newEntry - } - } - - /** - * Creates a new entry. - * - * @param segment to create the entry for - * @param key of the entry - * @param hash of the key - * @param next entry in the same bucket - */ - abstract fun newEntry( - segment: Segment?, - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry - - /** - * Copies an entry, assigning it a new `next` entry. - * - * @param original the entry to copy - * @param newNext entry in the same bucket - */ - // Guarded By Segment.this - open fun copyEntry( - segment: Segment?, - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry { - return newEntry(segment, original.key, original.hash, newNext) - } - - // Guarded By Segment.this - fun copyAccessEntry( - original: ReferenceEntry, - newEntry: ReferenceEntry, - ) { - // TODO(fry): when we link values instead of entries this method can go - // away, as can connectAccessOrder, nullifyAccessOrder. - newEntry.accessTime = original.accessTime - connectAccessOrder(original.previousInAccessQueue, newEntry) - connectAccessOrder(newEntry, original.nextInAccessQueue) - nullifyAccessOrder(original) - } - - // Guarded By Segment.this - fun copyWriteEntry( - original: ReferenceEntry, - newEntry: ReferenceEntry, - ) { - // TODO(fry): when we link values instead of entries this method can go - // away, as can connectWriteOrder, nullifyWriteOrder. - newEntry.writeTime = original.writeTime - connectWriteOrder(original.previousInWriteQueue, newEntry) - connectWriteOrder(newEntry, original.nextInWriteQueue) - nullifyWriteOrder(original) - } - - companion object { - /** - * Masks used to compute indices in the following table. - */ - private const val ACCESS_MASK = 1 - private const val WRITE_MASK = 2 - - /** - * Look-up table for factories. - */ - private val factories = arrayOf(Strong, StrongAccess, StrongWrite, StrongAccessWrite) - - fun getFactory( - usesAccessQueue: Boolean, - usesWriteQueue: Boolean, - ): EntryFactory { - val flags = ((if (usesAccessQueue) ACCESS_MASK else 0) or if (usesWriteQueue) WRITE_MASK else 0) - return factories[flags] - } - } - } - - /** - * A reference to a value. - */ - private interface ValueReference { - /** - * Returns the value. Does not block or throw exceptions. - */ - fun get(): V? - - /** - * Returns the weight of this entry. This is assumed to be static between calls to setValue. - */ - val weight: Int - - /** - * Returns the entry associated with this value reference, or `null` if this value - * reference is independent of any entry. - */ - val entry: ReferenceEntry? - - /** - * Creates a copy of this reference for the given entry. - * - * - * - * `value` may be null only for a loading reference. - */ - - fun copyFor( - value: V?, - entry: ReferenceEntry?, - ): ValueReference - - /** - * Notifify pending loads that a new value was set. This is only relevant to loading - * value references. - */ - fun notifyNewValue(newValue: V) - - /** - * Returns true if this reference contains an active value, meaning one that is still considered - * present in the cache. Active values consist of live values, which are returned by cache - * lookups, and dead values, which have been evicted but awaiting removal. Non-active values - * consist strictly of loading values, though during refresh a value may be both active and - * loading. - */ - val isActive: Boolean - } - - /** - * An entry in a reference map. - * - * - * Entries in the map can be in the following states: - * - * - * Valid: - * - Live: valid key/value are set - * - Loading: loading is pending - * - * - * Invalid: - * - Expired: time expired (key/value may still be set) - * - Collected: key/value was partially collected, but not yet cleaned up - * - Unset: marked as unset, awaiting cleanup or reuse - */ - private interface ReferenceEntry { - /** - * Returns the value reference from this entry. - */ - /** - * Sets the value reference for this entry. - */ - var valueReference: ValueReference? - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - - /** - * Returns the next entry in the chain. - */ - val next: ReferenceEntry? - get() = throw UnsupportedOperationException() - - /** - * Returns the entry's hash. - */ - val hash: Int - get() = throw UnsupportedOperationException() - - /** - * Returns the key for this entry. - */ - val key: K - get() = throw UnsupportedOperationException() - /* - * Used by entries that use access order. Access entries are maintained in a doubly-linked list. - * New entries are added at the tail of the list at write time; stale entries are expired from - * the head of the list. - */ - /** - * Returns the time that this entry was last accessed, in ns. - */ - /** - * Sets the entry access time in ns. - */ - var accessTime: Long - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - /** - * Returns the next entry in the access queue. - */ - /** - * Sets the next entry in the access queue. - */ - var nextInAccessQueue: ReferenceEntry - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - /** - * Returns the previous entry in the access queue. - */ - /** - * Sets the previous entry in the access queue. - */ - var previousInAccessQueue: ReferenceEntry - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - /* - * Implemented by entries that use write order. Write entries are maintained in a - * doubly-linked list. New entries are added at the tail of the list at write time and stale - * entries are expired from the head of the list. - */ - /** - * Returns the time that this entry was last written, in ns. - */ - /** - * Sets the entry write time in ns. - */ - var writeTime: Long - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - /** - * Returns the next entry in the write queue. - */ - /** - * Sets the next entry in the write queue. - */ - var nextInWriteQueue: ReferenceEntry - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - /** - * Returns the previous entry in the write queue. - */ - /** - * Sets the previous entry in the write queue. - */ - var previousInWriteQueue: ReferenceEntry - get() = throw UnsupportedOperationException() - set(_) = throw UnsupportedOperationException() - } - - private object NullEntry : ReferenceEntry { - override var valueReference: ValueReference? - get() = null - set(_) {} - - override val next: ReferenceEntry? - get() = null - - override val hash: Int - get() = 0 - - override val key: Any - get() = Unit - - override var accessTime: Long - get() = 0 - set(_) {} - - override var nextInAccessQueue: ReferenceEntry - get() = this - set(_) {} - - override var previousInAccessQueue: ReferenceEntry - get() = this - set(_) {} - - override var writeTime: Long - get() = 0 - set(_) {} - - override var nextInWriteQueue: ReferenceEntry - get() = this - set(_) {} - - override var previousInWriteQueue: ReferenceEntry - get() = this - set(_) {} - } - - /* - * Note: All of this duplicate code sucks, but it saves a lot of memory. If only Java had mixins! - * To maintain this code, make a change for the strong reference type. Then, cut and paste, and - * replace "Strong" with "Soft" or "Weak" within the pasted text. The primary difference is that - * strong entries store the key reference directly while soft and weak entries delegate to their - * respective superclasses. - */ - - /** - * Used for strongly-referenced keys. - */ - private open class StrongEntry( - override val key: K, // The code below is exactly the same for each entry type. - override val hash: Int, - override val next: ReferenceEntry?, - ) : ReferenceEntry { - private val _valueReference = atomic?>(unset()) - override var valueReference: ValueReference? = _valueReference.value - } - - private class StrongAccessEntry( - key: K, - hash: Int, - next: ReferenceEntry?, - ) : - StrongEntry(key, hash, next) { - // The code below is exactly the same for each access entry type. - - private val _accessTime = atomic(Long.MAX_VALUE) - override var accessTime = _accessTime.value - - // Guarded By Segment.this - override var nextInAccessQueue: ReferenceEntry = nullEntry() - - // Guarded By Segment.this - override var previousInAccessQueue: ReferenceEntry = nullEntry() - } - - private class StrongWriteEntry( - key: K, - hash: Int, - next: ReferenceEntry?, - ) : - StrongEntry(key, hash, next) { - // The code below is exactly the same for each write entry type. - private val _writeTime = atomic(Long.MAX_VALUE) - override var writeTime = _writeTime.value - - // Guarded By Segment.this - override var nextInWriteQueue: ReferenceEntry = nullEntry() - - // Guarded By Segment.this - override var previousInWriteQueue: ReferenceEntry = nullEntry() - } - - private class StrongAccessWriteEntry( - key: K, - hash: Int, - next: ReferenceEntry?, - ) : - StrongEntry(key, hash, next) { - // The code below is exactly the same for each access entry type. - private val _accessTime = atomic(Long.MAX_VALUE) - override var accessTime: Long = _accessTime.value - - // Guarded By Segment.this - override var nextInAccessQueue: ReferenceEntry = nullEntry() - - // Guarded By Segment.this - override var previousInAccessQueue: ReferenceEntry = nullEntry() - - // The code below is exactly the same for each write entry type. - private val _writeTime = atomic(Long.MAX_VALUE) - override var writeTime: Long = _writeTime.value - - // Guarded By Segment.this - override var nextInWriteQueue: ReferenceEntry = nullEntry() - - // Guarded By Segment.this - override var previousInWriteQueue: ReferenceEntry = nullEntry() - } - - /** - * References a strong value. - */ - private open class StrongValueReference(private val referent: V) : - ValueReference { - override fun get(): V = referent - - override val weight: Int = 1 - override val entry: ReferenceEntry? = null - - override fun copyFor( - value: V?, - entry: ReferenceEntry?, - ): ValueReference = this - - override val isActive: Boolean = true - - override fun notifyNewValue(newValue: V) {} - } - - /** - * References a strong value. - */ - private class WeightedStrongValueReference( - referent: V, - override val weight: Int, - ) : - StrongValueReference(referent) - - /** - * This method is a convenience for testing. Code should call [Segment.newEntry] directly. - */ - - private fun newEntry( - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry { - val segment = segmentFor(hash) - segment.reentrantLock.lock() - return try { - segment.newEntry(key, hash, next) - } finally { - segment.reentrantLock.unlock() - } - } - - /** - * This method is a convenience for testing. Code should call [Segment.copyEntry] directly. - */ - // Guarded By Segment.this - private fun copyEntry( - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry? { - val hash = original.hash - return segmentFor(hash).copyEntry(original, newNext) - } - - /** - * This method is a convenience for testing. Code should call [Segment.setValue] instead. - */ - // Guarded By Segment.this - private fun newValueReference( - entry: ReferenceEntry, - value: V, - weight: Int, - ): ValueReference { - val hash = entry.hash - return valueStrength.referenceValue(segmentFor(hash), entry, value, weight) - } - - private fun hash(key: K): Int = rehash(key.hashCode()) - - /** - * Returns the segment that should be used for a key with the given hash. - * - * @param hash the hash code for the key - * @return the segment - */ - private fun segmentFor(hash: Int): Segment = - // TODO(fry): Lazily create segments? - segments[hash ushr segmentShift and segmentMask] as Segment - - private fun createSegment( - initialCapacity: Int, - maxSegmentWeight: Long, - ): Segment = Segment(this, initialCapacity, maxSegmentWeight) - // expiration - - /** - * Returns true if the entry has expired. - */ - private fun isExpired( - entry: ReferenceEntry, - now: Long, - ): Boolean = - if (expiresAfterAccess && now - entry.accessTime >= expireAfterAccessNanos) { - true - } else { - expiresAfterWrite && now - entry.writeTime >= expireAfterWriteNanos - } - - // Inner Classes - - private class SegmentTable(val size: Int) { - private val table: AtomicArray?> = atomicArrayOfNulls(size) - - operator fun get(idx: Int) = table[idx].value - - operator fun set( - idx: Int, - value: ReferenceEntry?, - ) { - table[idx].value = value - } - } - - /** - * Segments are specialized versions of hash tables. - */ - private class Segment( - private val map: LocalCache, - initialCapacity: Int, - private val maxSegmentWeight: Long, - ) { - /* - * TODO(fry): Consider copying variables (like evictsBySize) from outer class into this class. - * It will require more memory but will reduce indirection. - */ - /* - * Segments maintain a table of entry lists that are ALWAYS kept in a consistent state, so can - * be read without locking. Next fields of nodes are immutable (final). All list additions are - * performed at the front of each bin. This makes it easy to check changes, and also fast to - * traverse. When nodes would otherwise be changed, new nodes are created to replace them. This - * works well for hash tables since the bin lists tend to be short. (The average length is less - * than two.) - * - * Read operations can thus proceed without locking, but rely on selected uses of volatiles to - * ensure that completed write operations performed by other threads are noticed. For most - * purposes, the "count" field, tracking the number of elements, serves as that volatile - * variable ensuring visibility. This is convenient because this field needs to be read in many - * read operations anyway: - * - * - All (unsynchronized) read operations must first read the "count" field, and should not - * look at table entries if it is 0. - * - * - All (synchronized) write operations should write to the "count" field after structurally - * changing any bin. The operations must not take any action that could even momentarily - * cause a concurrent read operation to see inconsistent data. This is made easier by the - * nature of the read operations in Map. For example, no operation can reveal that the table - * has grown but the threshold has not yet been updated, so there are no atomicity requirements - * for this with respect to reads. - * - * As a guide, all critical volatile reads and writes to the count field are marked in code - * comments. - */ - - val reentrantLock = reentrantLock() - - /** - * The number of live elements in this segment's region. - */ - private val count = atomic(0) - - /** - * The weight of the live elements in this segment's region. - */ - private var totalWeight: Long = 0 - - /** - * Number of updates that alter the size of the table. This is used during bulk-read methods to - * make sure they see a consistent snapshot: If modCounts change during a traversal of segments - * loading size or checking containsValue, then we might have an inconsistent view of state - * so (usually) must retry. - */ - private var modCount = 0 - - /** - * The table is expanded when its size exceeds this threshold. (The value of this field is - * always `(int) (capacity * 0.75)`.) - */ - private var threshold = 0 - - /** - * The per-segment table. - */ - private val table: AtomicRef> - - /** - * The recency queue is used to record which entries were accessed for updating the access - * list's ordering. It is drained as a batch operation when either the DRAIN_THRESHOLD is - * crossed or a write occurs on the segment. - */ - private val recencyQueue: Queue> - - /** - * A counter of the number of reads since the last write, used to drain queues on a small - * fraction of read operations. - */ - private val readCount = atomic(0) - - /** - * A queue of elements currently in the map, ordered by write time. Elements are added to the - * tail of the queue on write. - */ - private val writeQueue: MutableQueue> - - /** - * A queue of elements currently in the map, ordered by access time. Elements are added to the - * tail of the queue on access (note that writes count as accesses). - */ - private val accessQueue: MutableQueue> - - fun newEntry( - key: K, - hash: Int, - next: ReferenceEntry?, - ): ReferenceEntry = map.entryFactory.newEntry(this, key, hash, next) - - /** - * Copies `original` into a new entry chained to `newNext`. Returns the new entry, - * or `null` if `original` was already garbage collected. - */ - fun copyEntry( - original: ReferenceEntry, - newNext: ReferenceEntry?, - ): ReferenceEntry? { - val valueReference = original.valueReference - val value = valueReference!!.get() - if (value == null && valueReference.isActive) { - // value collected - return null - } - val newEntry = map.entryFactory.copyEntry(this, original, newNext) - newEntry.valueReference = valueReference.copyFor(value, newEntry) - return newEntry - } - - /** - * Sets a new value of an entry. Adds newly created entries at the end of the access queue. - */ - fun setValue( - entry: ReferenceEntry, - key: K, - value: V, - now: Long, - ) { - val previous = entry.valueReference - val weight = map.weigher(key, value) - if (weight < 0) throw IllegalStateException("Weights must be non-negative") - entry.valueReference = map.valueStrength.referenceValue(this, entry, value, weight) - recordWrite(entry, weight, now) - previous?.notifyNewValue(value) - } - - // recency queue, shared by expiration and eviction - - /** - * Records the relative order in which this read was performed by adding `entry` to the - * recency queue. At write-time, or when the queue is full past the threshold, the queue will - * be drained and the entries therein processed. - * - * - * - * Note: locked reads should use [.recordLockedRead]. - */ - private fun recordRead( - entry: ReferenceEntry, - now: Long, - ) { - if (map.recordsAccess) { - entry.accessTime = now - } - recencyQueue.add(entry) - } - - /** - * Updates the eviction metadata that `entry` was just read. This currently amounts to - * adding `entry` to relevant eviction lists. - * - * - * - * Note: this method should only be called under lock, as it directly manipulates the - * eviction queues. Unlocked reads should use [.recordRead]. - */ - private fun recordLockedRead( - entry: ReferenceEntry, - now: Long, - ) { - if (map.recordsAccess) { - entry.accessTime = now - } - accessQueue.add(entry) - } - - /** - * Updates eviction metadata that `entry` was just written. This currently amounts to - * adding `entry` to relevant eviction lists. - */ - private fun recordWrite( - entry: ReferenceEntry, - weight: Int, - now: Long, - ) { - // we are already under lock, so drain the recency queue immediately - drainRecencyQueue() - totalWeight += weight.toLong() - if (map.recordsAccess) { - entry.accessTime = now - } - if (map.recordsWrite) { - entry.writeTime = now - } - accessQueue.add(entry) - writeQueue.add(entry) - } - - /** - * Drains the recency queue, updating eviction metadata that the entries therein were read in - * the specified relative order. This currently amounts to adding them to relevant eviction - * lists (accounting for the fact that they could have been removed from the map since being - * added to the recency queue). - */ - private fun drainRecencyQueue() { - while (true) { - val e = recencyQueue.poll() ?: break - // An entry may be in the recency queue despite it being removed from - // the map . This can occur when the entry was concurrently read while a - // writer is removing it from the segment or after a clear has removed - // all of the segment's entries. - if (accessQueue.contains(e)) { - accessQueue.add(e) - } - } - } - // expiration - - /** - * Cleanup expired entries when the lock is available. - */ - private fun tryExpireEntries(now: Long) { - if (reentrantLock.tryLock()) { - try { - expireEntries(now) - } finally { - reentrantLock.unlock() - // don't call postWriteCleanup as we're in a read - } - } - } - - private fun expireEntries(now: Long) { - drainRecencyQueue() - while (true) { - val e = writeQueue.peek()?.takeIf { map.isExpired(it, now) } ?: break - if (!removeEntry(e, e.hash, RemovalCause.EXPIRED)) { - throw AssertionError() - } - } - - while (true) { - val e = accessQueue.peek()?.takeIf { map.isExpired(it, now) } ?: break - if (!removeEntry(e, e.hash, RemovalCause.EXPIRED)) { - throw AssertionError() - } - } - } - - // eviction - private fun enqueueNotification( - entry: ReferenceEntry, - cause: RemovalCause?, - ) { - enqueueNotification(entry.key, entry.hash, entry.valueReference, cause) - } - - private fun enqueueNotification( - key: K?, - hash: Int, - valueReference: ValueReference?, - cause: RemovalCause?, - ) { - valueReference?.weight?.toLong()?.apply { - totalWeight -= this - } - } - - /** - * Performs eviction if the segment is over capacity. Avoids flushing the entire cache if the - * newest entry exceeds the maximum weight all on its own. - * - * @param newest the most recently added entry - */ - private fun evictEntries(newest: ReferenceEntry) { - if (!map.evictsBySize) { - return - } - drainRecencyQueue() - - // If the newest entry by itself is too heavy for the segment, don't bother evicting - // anything else, just that - if (newest.valueReference!!.weight > maxSegmentWeight) { - if (!removeEntry(newest, newest.hash, RemovalCause.SIZE)) { - throw AssertionError() - } - } - while (totalWeight > maxSegmentWeight) { - val e = nextEvictable - if (!removeEntry(e, e.hash, RemovalCause.SIZE)) { - throw AssertionError() - } - } - } - - // TODO(fry): instead implement this with an eviction head - - private val nextEvictable: ReferenceEntry - get() { - for (e in accessQueue) { - val weight = e.valueReference!!.weight - if (weight > 0) { - return e - } - } - throw AssertionError() - } - - /** - * Returns first entry of bin for given hash. - */ - private fun getFirst(hash: Int): ReferenceEntry? { - // read this volatile field only once - val table = table.value - return table[hash and table.size - 1] - } - - // Specialized implementations of map methods - private fun getEntry( - key: K, - hash: Int, - ): ReferenceEntry? { - var e = getFirst(hash) - while (e != null) { - if (e.hash != hash) { - e = e.next - continue - } - val entryKey = e.key - if (key == entryKey) { - return e - } - e = e.next - } - return null - } - - private fun getLiveEntry( - key: K, - hash: Int, - now: Long, - ): ReferenceEntry? { - val e = getEntry(key, hash) - if (e == null) { - return null - } else if (map.isExpired(e, now)) { - tryExpireEntries(now) - return null - } - return e - } - - /** - * Gets the value from an entry. Returns null if the entry is invalid, partially-collected, - * loading, or expired. - */ - - fun get( - key: K, - hash: Int, - ): V? { - return try { - if (count.value != 0) { // read-volatile - val now = map.ticker() - val e = getLiveEntry(key, hash, now) ?: return null - val value = e.valueReference?.get() - if (value != null) { - recordRead(e, now) - return value - } - } - null - } finally { - postReadCleanup() - } - } - - fun getOrPut( - key: K, - hash: Int, - defaultValue: () -> V, - ): V { - reentrantLock.lock() - return try { - if (count.value != 0) { // read-volatile - val now = map.ticker() - val e = getLiveEntry(key, hash, now) - val value = e?.valueReference?.get() - if (value != null) { - recordRead(e, now) - return value - } - } - val default = defaultValue() - put(key, hash, default, false) - default - } finally { - reentrantLock.unlock() - postReadCleanup() - } - } - - fun put( - key: K, - hash: Int, - value: V, - onlyIfAbsent: Boolean, - ): V? { - reentrantLock.lock() - return try { - val now = map.ticker() - preWriteCleanup(now) - if (count.value + 1 > threshold) { // ensure capacity - expand() - } - val table = table.value - val index = hash and table.size - 1 - val first = table[index] - - // Look for an existing entry. - var e: ReferenceEntry? = first - while (e != null) { - val entryKey = e.key - if (e.hash == hash && key == entryKey) { - // We found an existing entry. - val valueReference = e.valueReference - val entryValue = valueReference!!.get() - return when { - entryValue == null -> { - ++modCount - val newCount = - if (valueReference.isActive) { - enqueueNotification( - key, - hash, - valueReference, - RemovalCause.COLLECTED, - ) - setValue(e, key, value, now) - count.value // count remains unchanged - } else { - setValue(e, key, value, now) - count.value + 1 - } - count.value = newCount // write-volatile - evictEntries(e) - null - } - - onlyIfAbsent -> { - // Mimic - // "if (!map.containsKey(key)) ... - // else return map.get(key); - recordLockedRead(e, now) - entryValue - } - - else -> { - // clobber existing entry, count remains unchanged - ++modCount - enqueueNotification( - key, - hash, - valueReference, - RemovalCause.REPLACED, - ) - setValue(e, key, value, now) - evictEntries(e) - entryValue - } - } - } - e = e.next - } - - // Create a new entry. - ++modCount - val newEntry = newEntry(key, hash, first) - setValue(newEntry, key, value, now) - table[index] = newEntry - count.plusAssign(1) - evictEntries(newEntry) - null - } finally { - reentrantLock.unlock() - postWriteCleanup() - } - } - - fun remove( - key: K, - hash: Int, - ): V? { - reentrantLock.lock() - return try { - val now = map.ticker() - preWriteCleanup(now) - val table = table.value - val index = hash and table.size - 1 - val first = table[index] - var e = first - while (e != null) { - val entryKey = e.key - if (e.hash == hash && key == entryKey) { - val valueReference = e.valueReference - val entryValue = valueReference!!.get() - val cause: RemovalCause = - when { - entryValue != null -> { - RemovalCause.EXPLICIT - } - - valueReference.isActive -> { - RemovalCause.COLLECTED - } - - else -> { - // currently loading - return null - } - } - ++modCount - val newFirst = - removeValueFromChain( - first!!, - e, - entryKey, - hash, - valueReference, - cause, - ) - val newCount = count.value - 1 - table[index] = newFirst - count.value = newCount // write-volatile - return entryValue - } - e = e.next - } - null - } finally { - reentrantLock.unlock() - postWriteCleanup() - } - } - - fun clear() { - if (count.value != 0) { // read-volatile - reentrantLock.lock() - try { - val table = table.value - for (i in 0 until table.size) { - var e = table[i] - while (e != null) { - // Loading references aren't actually in the map yet. - if (e.valueReference!!.isActive) { - enqueueNotification(e, RemovalCause.EXPLICIT) - } - e = e.next - } - } - for (i in 0 until table.size) { - table[i] = null - } - writeQueue.clear() - accessQueue.clear() - readCount.value = 0 - ++modCount - count.value = 0 // write-volatile - } finally { - reentrantLock.unlock() - postWriteCleanup() - } - } - } - - /** - * Expands the table if possible. - */ - private fun expand() { - val oldTable = table.value - val oldCapacity = oldTable.size - if (oldCapacity >= MAXIMUM_CAPACITY) { - return - } - - /* - * Reclassify nodes in each list to new Map. Because we are using power-of-two expansion, the - * elements from each bin must either stay at same index, or move with a power of two offset. - * We eliminate unnecessary node creation by catching cases where old nodes can be reused - * because their next fields won't change. Statistically, at the default threshold, only - * about one-sixth of them need cloning when a table doubles. The nodes they replace will be - * garbage collectable as soon as they are no longer referenced by any reader thread that may - * be in the midst of traversing table right now. - */ - var newCount = count.value - val newTable = SegmentTable(oldCapacity shl 1) - threshold = newTable.size * 3 / 4 - val newMask = newTable.size - 1 - for (oldIndex in 0 until oldCapacity) { - // We need to guarantee that any existing reads of old Map can - // proceed. So we cannot yet null out each bin. - val head = oldTable[oldIndex] ?: continue - - val next = head.next - val headIndex = head.hash and newMask - - // Single node on list - if (next == null) { - newTable[headIndex] = head - } else { - // Reuse the consecutive sequence of nodes with the same target - // index from the end of the list. tail points to the first - // entry in the reusable list. - var tail = head - var tailIndex = headIndex - var entry = next - while (entry != null) { - val newIndex = entry.hash and newMask - if (newIndex != tailIndex) { - // The index changed. We'll need to copy the previous entry. - tailIndex = newIndex - tail = entry - } - entry = entry.next - } - newTable[tailIndex] = tail - - // Clone nodes leading up to the tail. - var headEntry = head - while (headEntry !== tail) { - val newIndex = headEntry.hash and newMask - val newNext = newTable[newIndex] - val newFirst = copyEntry(headEntry, newNext) - if (newFirst != null) { - newTable[newIndex] = newFirst - } else { - removeCollectedEntry(headEntry) - newCount-- - } - headEntry = headEntry.next ?: break - } - } - } - table.value = newTable - count.value = newCount - } - - private fun removeValueFromChain( - first: ReferenceEntry, - entry: ReferenceEntry, - key: K, - hash: Int, - valueReference: ValueReference, - cause: RemovalCause?, - ): ReferenceEntry? { - enqueueNotification(key, hash, valueReference, cause) - writeQueue.remove(entry) - accessQueue.remove(entry) - return removeEntryFromChain(first, entry) - } - - private fun removeEntryFromChain( - first: ReferenceEntry, - entry: ReferenceEntry, - ): ReferenceEntry? { - var newCount = count.value - var newFirst = entry.next - var e = first - while (e !== entry) { - val next = copyEntry(e, newFirst) - if (next != null) { - newFirst = next - } else { - removeCollectedEntry(e) - newCount-- - } - e = e.next ?: break - } - count.value = newCount - return newFirst - } - - private fun removeCollectedEntry(entry: ReferenceEntry) { - enqueueNotification(entry, RemovalCause.COLLECTED) - writeQueue.remove(entry) - accessQueue.remove(entry) - } - - private fun removeEntry( - entry: ReferenceEntry, - hash: Int, - cause: RemovalCause?, - ): Boolean { - val table = table.value - val index = hash and table.size - 1 - val first = table[index] - var e = first - - while (e != null) { - if (e === entry) { - ++modCount - val newFirst = - removeValueFromChain( - first!!, - e, - e.key, - hash, - e.valueReference!!, - cause, - ) - val newCount = count.value - 1 - table[index] = newFirst - count.value = newCount // write-volatile - return true - } - e = e.next - } - return false - } - - /** - * Performs routine cleanup following a read. Normally cleanup happens during writes. If cleanup - * is not observed after a sufficient number of reads, try cleaning up from the read thread. - */ - private fun postReadCleanup() { - if (readCount.incrementAndGet() and DRAIN_THRESHOLD == 0) { - cleanUp() - } - } - - /** - * Performs routine cleanup prior to executing a write. This should be called every time a - * write thread acquires the segment lock, immediately after acquiring the lock. - * - * - * - * Post-condition: expireEntries has been run. - */ - private fun preWriteCleanup(now: Long) { - runLockedCleanup(now) - } - - /** - * Performs routine cleanup following a write. - */ - private fun postWriteCleanup() { - runUnlockedCleanup() - } - - fun cleanUp() { - val now = map.ticker() - runLockedCleanup(now) - runUnlockedCleanup() - } - - private fun runLockedCleanup(now: Long) { - if (reentrantLock.tryLock()) { - try { - expireEntries(now) // calls drainRecencyQueue - readCount.value = 0 - } finally { - reentrantLock.unlock() - } - } - } - - private fun runUnlockedCleanup() { - // locked cleanup may generate notifications we can send unlocked - /*if (!isHeldByCurrentThread) { - map.processPendingNotifications() - }*/ - } - - fun activeEntries(): Map { - // read-volatile - if (count.value == 0) return emptyMap() - reentrantLock.lock() - return try { - val activeMap = mutableMapOf() - val table = table.value - for (i in 0 until table.size) { - var e = table[i] - while (e != null) { - if (e.valueReference?.isActive == true) { - activeMap[e.key] = e.valueReference?.get()!! - } - e = e.next - } - } - activeMap.ifEmpty { emptyMap() } - } finally { - reentrantLock.unlock() - } - } - - init { - threshold = initialCapacity * 3 / 4 // 0.75 - if (!map.customWeigher && threshold.toLong() == maxSegmentWeight) { - // prevent spurious expansion before eviction - threshold++ - } - table = atomic(SegmentTable(initialCapacity)) - recencyQueue = if (map.usesAccessQueue) AtomicLinkedQueue() else discardingQueue() - writeQueue = if (map.usesWriteQueue) WriteQueue() else discardingQueue() - accessQueue = if (map.usesAccessQueue) AccessQueue() else discardingQueue() - } - } - // Queues - - private interface Queue { - fun poll(): T? - - fun add(value: T) - } - - private interface MutableQueue : Queue, Iterable { - fun peek(): E? - - fun isEmpty(): Boolean - - val size: Int - - fun clear() - - fun remove(element: E): Boolean - - fun contains(element: E): Boolean - } - - private class AtomicLinkedQueue : Queue { - private val head: AtomicRef> = atomic(Node(null)) - private val tail: AtomicRef> = atomic(head.value) - - private class Node(val value: T) { - val next = atomic?>(null) - } - - override fun add(value: T) { - val node: Node = Node(value) - tail.loop { curTail -> - val curNext = curTail.next.value - if (curNext != null) { - tail.compareAndSet(curTail, curNext) - return@loop - } - if (curTail.next.compareAndSet(null, node)) { - tail.compareAndSet(curTail, node) - return - } - } - } - - override fun poll(): T? { - head.loop { curHead -> - val next = curHead.next.value ?: return null - if (head.compareAndSet(curHead, next)) return next.value - } - } - } - - /** - * A custom queue for managing eviction order. Note that this is tightly integrated with `ReferenceEntry`, upon which it relies to perform its linking. - * - * - * - * Note that this entire implementation makes the assumption that all elements which are in - * the map are also in this queue, and that all elements not in the queue are not in the map. - * - * - * - * The benefits of creating our own queue are that (1) we can replace elements in the middle - * of the queue as part of copyWriteEntry, and (2) the contains method is highly optimized - * for the current model. - */ - - private class WriteQueue : MutableQueue> { - private val head: ReferenceEntry = - object : ReferenceEntry { - override var writeTime: Long - get() = Long.MAX_VALUE - set(_) {} - override var nextInWriteQueue: ReferenceEntry = this - override var previousInWriteQueue: ReferenceEntry = this - } - - // implements Queue - override fun add(value: ReferenceEntry) { - // unlink - connectWriteOrder(value.previousInWriteQueue, value.nextInWriteQueue) - - // add to tail - connectWriteOrder(head.previousInWriteQueue, value) - connectWriteOrder(value, head) - } - - override fun peek(): ReferenceEntry? { - val next = head.nextInWriteQueue - return if (next === head) null else next - } - - override fun poll(): ReferenceEntry? { - val next = head.nextInWriteQueue - if (next === head) { - return null - } - remove(next) - return next - } - - override fun remove(element: ReferenceEntry): Boolean { - val previous = element.previousInWriteQueue - val next = element.nextInWriteQueue - connectWriteOrder(previous, next) - nullifyWriteOrder(element) - return next !== NullEntry - } - - override fun contains(element: ReferenceEntry): Boolean = element.nextInWriteQueue !== NullEntry - - override fun isEmpty(): Boolean = head.nextInWriteQueue === head - - override val size: Int - get() { - var size = 0 - var e = head.nextInWriteQueue - while (e !== head) { - size++ - e = e.nextInWriteQueue - } - return size - } - - override fun clear() { - var e = head.nextInWriteQueue - while (e !== head) { - val next = e.nextInWriteQueue - nullifyWriteOrder(e) - e = next - } - head.nextInWriteQueue = head - head.previousInWriteQueue = head - } - - override fun iterator(): Iterator> = - iterator { - var value = peek() - while (value != null) { - yield(value) - val next = value.nextInWriteQueue - value = if (next === head) null else next - } - } - } - - /** - * A custom queue for managing access order. Note that this is tightly integrated with - * `ReferenceEntry`, upon which it reliese to perform its linking. - * - * - * - * Note that this entire implementation makes the assumption that all elements which are in - * the map are also in this queue, and that all elements not in the queue are not in the map. - * - * - * - * The benefits of creating our own queue are that (1) we can replace elements in the middle - * of the queue as part of copyWriteEntry, and (2) the contains method is highly optimized - * for the current model. - */ - private class AccessQueue : MutableQueue> { - private val head: ReferenceEntry = - object : ReferenceEntry { - override var accessTime: Long - get() = Long.MAX_VALUE - set(_) {} - override var nextInAccessQueue: ReferenceEntry = this - override var previousInAccessQueue: ReferenceEntry = this - } - - // implements Queue - override fun add(value: ReferenceEntry) { - // unlink - connectAccessOrder(value.previousInAccessQueue, value.nextInAccessQueue) - - // add to tail - connectAccessOrder(head.previousInAccessQueue, value) - connectAccessOrder(value, head) - } - - override fun peek(): ReferenceEntry? { - val next = head.nextInAccessQueue - return if (next === head) null else next - } - - override fun poll(): ReferenceEntry? { - val next = head.nextInAccessQueue - if (next === head) { - return null - } - remove(next) - return next - } - - override fun remove(element: ReferenceEntry): Boolean { - val previous = element.previousInAccessQueue - val next = element.nextInAccessQueue - connectAccessOrder(previous, next) - nullifyAccessOrder(element) - return next !== NullEntry - } - - override fun contains(element: ReferenceEntry): Boolean = element.nextInAccessQueue !== NullEntry - - override fun isEmpty(): Boolean = head.nextInAccessQueue === head - - override val size: Int - get() { - var size = 0 - var e = head.nextInAccessQueue - while (e !== head) { - size++ - e = e.nextInAccessQueue - } - return size - } - - override fun clear() { - var e = head.nextInAccessQueue - while (e !== head) { - val next = e.nextInAccessQueue - nullifyAccessOrder(e) - e = next - } - head.nextInAccessQueue = head - head.previousInAccessQueue = head - } - - override fun iterator(): Iterator> = - iterator { - var value = peek() - while (value != null) { - yield(value) - val next = value.nextInAccessQueue - value = if (next === head) null else next - } - } - } - - // Cache support - fun cleanUp() { - for (segment in segments) { - segment?.cleanUp() - } - } - - // ConcurrentMap methods - fun getIfPresent(key: K): V? { - val hash = hash(key) - return segmentFor(hash).get(key, hash) - } - - fun put( - key: K, - value: V, - ): V? { - val hash = hash(key) - return segmentFor(hash).put(key, hash, value, false) - } - - fun getOrPut( - key: K, - defaultValue: () -> V, - ): V { - val hash = hash(key) - return segmentFor(hash).getOrPut(key, hash, defaultValue) - } - - fun clear() { - for (segment in segments) { - segment?.clear() - } - } - - fun remove(key: K): V? { - val hash = hash(key) - return segmentFor(hash).remove(key, hash) - } - - fun getAllPresent(): Map { - return buildMap { - for (segment in segments) { - segment?.let { putAll(it.activeEntries()) } - } - } - } - - // Serialization Support - internal class LocalManualCache private constructor(private val localCache: LocalCache) : - Cache { - constructor(builder: CacheBuilder) : this(LocalCache(builder)) - - // Cache methods - override fun getIfPresent(key: K): V? { - return localCache.getIfPresent(key) - } - - override fun put( - key: K, - value: V, - ) { - localCache.put(key, value) - } - - override fun invalidate(key: K) { - localCache.remove(key) - } - - override fun getOrPut( - key: K, - valueProducer: () -> V, - ): V { - return localCache.getOrPut(key, valueProducer) - } - - override fun getAllPresent(keys: List<*>): Map { - return localCache.getAllPresent().filterKeys { it in keys } - } - - override fun getAllPresent(): Map { - return localCache.getAllPresent() - } - - override fun invalidateAll(keys: List) { - TODO("Not yet implemented") - } - - override fun putAll(map: Map) { - TODO("Not yet implemented") - } - - override fun invalidateAll() { - localCache.clear() - } - - override fun size(): Long { - TODO("Not yet implemented") - } - } - - companion object { - /* - * The basic strategy is to subdivide the table among Segments, each of which itself is a - * concurrently readable hash table. The map supports non-blocking reads and concurrent writes - * across different segments. - * - * If a maximum size is specified, a best-effort bounding is performed per segment, using a - * page-replacement algorithm to determine which entries to evict when the capacity has been - * exceeded. - * - * The page replacement algorithm's data structures are kept casually consistent with the map. The - * ordering of writes to a segment is sequentially consistent. An update to the map and recording - * of reads may not be immediately reflected on the algorithm's data structures. These structures - * are guarded by a lock and operations are applied in batches to avoid lock contention. The - * penalty of applying the batches is spread across threads so that the amortized cost is slightly - * higher than performing just the operation without enforcing the capacity constraint. - * - * This implementation uses a per-segment queue to record a memento of the additions, removals, - * and accesses that were performed on the map. The queue is drained on writes and when it exceeds - * its capacity threshold. - * - * The Least Recently Used page replacement algorithm was chosen due to its simplicity, high hit - * rate, and ability to be implemented with O(1) time complexity. The initial LRU implementation - * operates per-segment rather than globally for increased implementation simplicity. We expect - * the cache hit rate to be similar to that of a global LRU algorithm. - */ - // Constants - private val OneWeigher: Weigher = { _, _ -> 1 } - - /** - * The maximum capacity, used if a higher value is implicitly specified by either of the - * constructors with arguments. MUST be a power of two <= 1<<30 to ensure that entries are - * indexable using ints. - */ - const val MAXIMUM_CAPACITY = 1 shl 30 - - /** - * The maximum number of segments to allow; used to bound constructor arguments. - */ - const val MAX_SEGMENTS = 1 shl 16 // slightly conservative - - /** - * Number of cache access operations that can be buffered per segment before the cache's recency - * ordering information is updated. This is used to avoid lock contention by recording a memento - * of reads and delaying a lock acquisition until the threshold is crossed or a mutation occurs. - * - * - * - * This must be a (2^n)-1 as it is used as a mask. - */ - const val DRAIN_THRESHOLD = 0x3F - - /** - * Placeholder. Indicates that the value hasn't been set yet. - */ - private val UNSET: ValueReference = - object : ValueReference { - override fun get(): Any? { - return null - } - - override val weight: Int - get() = 0 - override val entry: ReferenceEntry? - get() = null - - override fun copyFor( - value: Any?, - entry: ReferenceEntry?, - ): ValueReference { - return this - } - - override val isActive: Boolean - get() = false - - override fun notifyNewValue(newValue: Any) {} - } - - /** - * Singleton placeholder that indicates a value is being loaded. - */ - @Suppress("UNCHECKED_CAST") - private fun unset() = UNSET as ValueReference - - @Suppress("UNCHECKED_CAST") - private fun nullEntry() = NullEntry as ReferenceEntry - - private val DISCARDING_QUEUE: MutableQueue = - object : MutableQueue { - override fun add(value: Any) {} - - override fun peek(): Any? = null - - override fun poll(): Any? = null - - override fun iterator(): MutableIterator = HashSet().iterator() - - override val size: Int = 0 - - override fun isEmpty(): Boolean = true - - override fun clear() {} - - override fun remove(element: Any): Boolean = false - - override fun contains(element: Any): Boolean = false - } - - /** - * Queue that discards all elements. - */ - @Suppress("UNCHECKED_CAST") - private fun discardingQueue(): MutableQueue = DISCARDING_QUEUE as MutableQueue - - /** - * Applies a supplemental hash function to a given hash code, which defends against poor quality - * hash functions. This is critical when the concurrent hash map uses power-of-two length hash - * tables, that otherwise encounter collisions for hash codes that do not differ in lower or - * upper bits. - * - * @param hash hash code - */ - fun rehash(hash: Int): Int { - // Spread bits to regularize both segment and index locations, - // using variant of single-word Wang/Jenkins hash. - // TODO(kevinb): use Hashing/move this to Hashing? - var h = hash - h += h shl 15 xor -0x3283 - h = h xor (h ushr 10) - h += h shl 3 - h = h xor (h ushr 6) - h += (h shl 2) + (h shl 14) - return h xor (h ushr 16) - } - - // queues - // Guarded By Segment.this - private fun connectAccessOrder( - previous: ReferenceEntry, - next: ReferenceEntry, - ) { - previous.nextInAccessQueue = next - next.previousInAccessQueue = previous - } - - // Guarded By Segment.this - private fun nullifyAccessOrder(nulled: ReferenceEntry) { - val nullEntry: ReferenceEntry = nullEntry() - nulled.nextInAccessQueue = nullEntry - nulled.previousInAccessQueue = nullEntry - } - - // Guarded By Segment.this - private fun connectWriteOrder( - previous: ReferenceEntry, - next: ReferenceEntry, - ) { - previous.nextInWriteQueue = next - next.previousInWriteQueue = previous - } - - // Guarded By Segment.this - private fun nullifyWriteOrder(nulled: ReferenceEntry) { - val nullEntry: ReferenceEntry = nullEntry() - nulled.nextInWriteQueue = nullEntry - nulled.previousInWriteQueue = nullEntry - } - } - - /** - * Creates a new, empty map with the specified strategy, initial capacity and concurrency level. - */ - init { - this.maxWeight = - when { - builder.expireAfterAccess == Duration.ZERO || builder.expireAfterWrite == Duration.ZERO -> 0L - builder.weigher != null -> builder.maximumWeight - else -> builder.maximumSize - } - this.weigher = builder.weigher ?: OneWeigher as Weigher - - this.expireAfterAccessNanos = - (if (builder.expireAfterAccess == Duration.INFINITE) Duration.ZERO else builder.expireAfterAccess) - .inWholeNanoseconds - - this.expireAfterWriteNanos = - (if (builder.expireAfterWrite == Duration.INFINITE) Duration.ZERO else builder.expireAfterWrite) - .inWholeNanoseconds - - this.ticker = if (recordsTime) (builder.ticker ?: MonotonicTicker) else ({ 0L }) - this.entryFactory = EntryFactory.getFactory(usesAccessEntries, usesWriteEntries) - var initialCapacity = builder.initialCapacity.coerceAtMost(MAXIMUM_CAPACITY) - if (evictsBySize && !customWeigher) { - initialCapacity = min(initialCapacity, maxWeight.toInt()) - } - val concurrencyLevel = builder.concurrencyLevel.coerceAtMost(MAX_SEGMENTS) - // Find the lowest power-of-two segmentCount that exceeds concurrencyLevel, unless - // maximumSize/Weight is specified in which case ensure that each segment gets at least 10 - // entries. The special casing for size-based eviction is only necessary because that eviction - // happens per segment instead of globally, so too many segments compared to the maximum size - // will result in random eviction behavior. - var segmentShift = 0 - var segmentCount = 1 - while (segmentCount < concurrencyLevel && (!evictsBySize || segmentCount * 20 <= maxWeight)) { - ++segmentShift - segmentCount = segmentCount shl 1 - } - this.segmentShift = 32 - segmentShift - segmentMask = segmentCount - 1 - segments = arrayOfNulls(segmentCount) - var segmentCapacity = initialCapacity / segmentCount - if (segmentCapacity * segmentCount < initialCapacity) { - ++segmentCapacity - } - var segmentSize = 1 - while (segmentSize < segmentCapacity) { - segmentSize = segmentSize shl 1 - } - if (evictsBySize) { - // Ensure sum of segment max weights = overall max weights - var maxSegmentWeight = maxWeight / segmentCount + 1 - val remainder = maxWeight % segmentCount - for (i in segments.indices) { - if (i.toLong() == remainder) { - maxSegmentWeight-- - } - segments[i] = createSegment(segmentSize, maxSegmentWeight) - } - } else { - for (i in segments.indices) { - segments[i] = createSegment(segmentSize, -1) - } - } - } -} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MonotonicTicker.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MonotonicTicker.kt deleted file mode 100644 index 30e346448..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/MonotonicTicker.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -import kotlin.time.ExperimentalTime -import kotlin.time.TimeSource - -@OptIn(ExperimentalTime::class) -internal val MonotonicTicker: Ticker = TimeSource.Monotonic.markNow().let { timeMark -> { timeMark.elapsedNow().inWholeNanoseconds } } diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/RemovalCause.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/RemovalCause.kt deleted file mode 100644 index 2f9f209fa..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/RemovalCause.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -/** - * The reason why a cached entry was removed. - * @param wasEvicted True if entry removal was automatic due to eviction. That is, the cause of removal is neither [EXPLICIT] or [REPLACED]. - * @author Charles Fry - * @since 10.0 - */ -internal enum class RemovalCause(val wasEvicted: Boolean) { - EXPLICIT(false), - REPLACED(false), - COLLECTED(true), - EXPIRED(true), - SIZE(true), -} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt deleted file mode 100644 index 880b73042..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCache.kt +++ /dev/null @@ -1,171 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package org.mobilenativefoundation.store.cache5 - -import org.mobilenativefoundation.store.core5.KeyProvider -import org.mobilenativefoundation.store.core5.StoreData -import org.mobilenativefoundation.store.core5.StoreKey - -/** - * A class that represents a caching system with collection decomposition. - * Manages data with utility functions to get, invalidate, and add items to the cache. - * Depends on [StoreMultiCacheAccessor] for internal data management. - * @see [Cache]. - */ -class StoreMultiCache, Single : StoreData.Single, Collection : StoreData.Collection, Output : StoreData>( - private val keyProvider: KeyProvider, - singlesCache: Cache, Single> = CacheBuilder, Single>().build(), - collectionsCache: Cache, Collection> = CacheBuilder, Collection>().build(), -) : Cache { - private val accessor = - StoreMultiCacheAccessor( - singlesCache = singlesCache, - collectionsCache = collectionsCache, - ) - - private fun Key.castSingle() = this as StoreKey.Single - - private fun Key.castCollection() = this as StoreKey.Collection - - private fun StoreKey.Collection.cast() = this as Key - - private fun StoreKey.Single.cast() = this as Key - - override fun getIfPresent(key: Key): Output? { - return when (key) { - is StoreKey.Single<*> -> accessor.getSingle(key.castSingle()) as? Output - is StoreKey.Collection<*> -> accessor.getCollection(key.castCollection()) as? Output - else -> { - throw UnsupportedOperationException(invalidKeyErrorMessage(key)) - } - } - } - - override fun getOrPut( - key: Key, - valueProducer: () -> Output, - ): Output { - return when (key) { - is StoreKey.Single<*> -> { - val single = accessor.getSingle(key.castSingle()) as? Output - if (single != null) { - single - } else { - val producedSingle = valueProducer() - put(key, producedSingle) - producedSingle - } - } - - is StoreKey.Collection<*> -> { - val collection = accessor.getCollection(key.castCollection()) as? Output - if (collection != null) { - collection - } else { - val producedCollection = valueProducer() - put(key, producedCollection) - producedCollection - } - } - - else -> { - throw UnsupportedOperationException(invalidKeyErrorMessage(key)) - } - } - } - - override fun getAllPresent(keys: List<*>): Map { - val map = mutableMapOf() - keys.filterIsInstance>().forEach { key -> - when (key) { - is StoreKey.Collection -> { - val collection = accessor.getCollection(key) - collection?.let { map[key.cast()] = it as Output } - } - - is StoreKey.Single -> { - val single = accessor.getSingle(key) - single?.let { map[key.cast()] = it as Output } - } - } - } - - return map - } - - override fun getAllPresent(): Map { - return accessor.getAllPresent().mapKeys { (key, _) -> - when (key) { - is StoreKey.Collection -> key.cast() - is StoreKey.Single -> key.cast() - else -> throw UnsupportedOperationException(invalidKeyErrorMessage(key)) - } - } as Map - } - - override fun invalidateAll(keys: List) { - keys.forEach { key -> invalidate(key) } - } - - override fun invalidate(key: Key) { - when (key) { - is StoreKey.Single<*> -> accessor.invalidateSingle(key.castSingle()) - is StoreKey.Collection<*> -> accessor.invalidateCollection(key.castCollection()) - } - } - - override fun putAll(map: Map) { - map.entries.forEach { (key, value) -> put(key, value) } - } - - override fun put( - key: Key, - value: Output, - ) { - when (key) { - is StoreKey.Single<*> -> { - val single = value as Single - accessor.putSingle(key.castSingle(), single) - - val collectionKey = keyProvider.fromSingle(key.castSingle(), single) - val existingCollection = accessor.getCollection(collectionKey) - if (existingCollection != null) { - val updatedItems = - existingCollection.items.toMutableList().map { - if (it.id == single.id) { - single - } else { - it - } - } - val updatedCollection = existingCollection.copyWith(items = updatedItems) as Collection - accessor.putCollection(collectionKey, updatedCollection) - } - } - - is StoreKey.Collection<*> -> { - val collection = value as Collection - accessor.putCollection(key.castCollection(), collection) - - collection.items.forEach { - val single = it as? Single - if (single != null) { - accessor.putSingle(keyProvider.fromCollection(key.castCollection(), single), single) - } - } - } - } - } - - override fun invalidateAll() { - accessor.invalidateAll() - } - - override fun size(): Long { - return accessor.size() - } - - companion object { - fun invalidKeyErrorMessage(key: Any) = "Expected StoreKey.Single or StoreKey.Collection, but received ${key::class}" - } -} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt deleted file mode 100644 index d7a69e0b5..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/StoreMultiCacheAccessor.kt +++ /dev/null @@ -1,182 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -import kotlinx.atomicfu.locks.SynchronizedObject -import kotlinx.atomicfu.locks.synchronized -import org.mobilenativefoundation.store.core5.StoreData -import org.mobilenativefoundation.store.core5.StoreKey - -/** - * Responsible for managing and accessing cached data. - * Provides functionality to retrieve, store, and invalidate single items and collections of items. - * All operations are thread-safe, ensuring safe usage across multiple threads. - * - * The thread safety of this class is ensured through the use of synchronized blocks. - * Synchronized blocks guarantee only one thread can execute any of the methods at a time. - * This prevents concurrent modifications and ensures consistency of the data. - * - * @param Id The type of the identifier used for the data. - * @param Collection The type of the data collection. - * @param Single The type of the single data item. - * @property singlesCache The cache used to store single data items. - * @property collectionsCache The cache used to store collections of data items. - */ -class StoreMultiCacheAccessor, Single : StoreData.Single>( - private val singlesCache: Cache, Single>, - private val collectionsCache: Cache, Collection>, -) : SynchronizedObject() { - private val keys = mutableSetOf>() - - /** - * Retrieves a collection of items from the cache using the provided key. - * - * This operation is thread-safe. - * - * @param key The key used to retrieve the collection. - * @return The cached collection or null if it's not present. - */ - fun getCollection(key: StoreKey.Collection): Collection? = - synchronized(this) { - collectionsCache.getIfPresent(key) - } - - /** - * Retrieves an individual item from the cache using the provided key. - * - * This operation is thread-safe. - * - * @param key The key used to retrieve the single item. - * @return The cached single item or null if it's not present. - */ - fun getSingle(key: StoreKey.Single): Single? = - synchronized(this) { - singlesCache.getIfPresent(key) - } - - /** - * Retrieves all items from the cache. - * - * This operation is thread-safe. - */ - fun getAllPresent(): Map, Any> = - synchronized(this) { - val result = mutableMapOf, Any>() - for (key in keys) { - when (key) { - is StoreKey.Single -> { - val single = singlesCache.getIfPresent(key) - if (single != null) { - result[key] = single - } - } - - is StoreKey.Collection -> { - val collection = collectionsCache.getIfPresent(key) - if (collection != null) { - result[key] = collection - } - } - } - } - result - } - - /** - * Stores a collection of items in the cache and updates the key set. - * - * This operation is thread-safe. - * - * @param key The key associated with the collection. - * @param collection The collection to be stored in the cache. - */ - fun putCollection( - key: StoreKey.Collection, - collection: Collection, - ) = synchronized(this) { - collectionsCache.put(key, collection) - keys.add(key) - } - - /** - * Stores an individual item in the cache and updates the key set. - * - * This operation is thread-safe. - * - * @param key The key associated with the single item. - * @param single The single item to be stored in the cache. - */ - fun putSingle( - key: StoreKey.Single, - single: Single, - ) = synchronized(this) { - singlesCache.put(key, single) - keys.add(key) - } - - /** - * Removes all cache entries and clears the key set. - * - * This operation is thread-safe. - */ - fun invalidateAll() = - synchronized(this) { - collectionsCache.invalidateAll() - singlesCache.invalidateAll() - keys.clear() - } - - /** - * Removes an individual item from the cache and updates the key set. - * - * This operation is thread-safe. - * - * @param key The key associated with the single item to be invalidated. - */ - fun invalidateSingle(key: StoreKey.Single) = - synchronized(this) { - singlesCache.invalidate(key) - keys.remove(key) - } - - /** - * Removes a collection of items from the cache and updates the key set. - * - * This operation is thread-safe. - * - * @param key The key associated with the collection to be invalidated. - */ - fun invalidateCollection(key: StoreKey.Collection) = - synchronized(this) { - collectionsCache.invalidate(key) - keys.remove(key) - } - - /** - * Calculates the total count of items in the cache, including both single items and items in collections. - * - * This operation is thread-safe. - * - * @return The total count of items in the cache. - */ - fun size(): Long = - synchronized(this) { - var count = 0L - for (key in keys) { - when (key) { - is StoreKey.Single -> { - val single = singlesCache.getIfPresent(key) - if (single != null) { - count++ - } - } - - is StoreKey.Collection -> { - val collection = collectionsCache.getIfPresent(key) - if (collection != null) { - count += collection.items.size - } - } - } - } - count - } -} diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Ticker.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Ticker.kt deleted file mode 100644 index 434cfc61f..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Ticker.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -/** - * @return Number of nanoseconds elapsed since the ticker's fixed point of reference. - */ -typealias Ticker = () -> Long diff --git a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Weigher.kt b/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Weigher.kt deleted file mode 100644 index dbb73292e..000000000 --- a/cache/src/commonMain/kotlin/org/mobilenativefoundation/store/cache5/Weigher.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -/** - * @return Weight of a cache entry. Must be non-negative. There is no unit for entry weights. Rather, they are simply relative to each other. - */ -typealias Weigher = (key: Key, value: Value) -> Int diff --git a/cache/src/commonTest/kotlin/org/mobilenativefoundation/store/cache5/CacheTests.kt b/cache/src/commonTest/kotlin/org/mobilenativefoundation/store/cache5/CacheTests.kt deleted file mode 100644 index af1a11de5..000000000 --- a/cache/src/commonTest/kotlin/org/mobilenativefoundation/store/cache5/CacheTests.kt +++ /dev/null @@ -1,108 +0,0 @@ -package org.mobilenativefoundation.store.cache5 - -import kotlinx.coroutines.test.runTest -import kotlin.test.Ignore -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.time.Duration.Companion.milliseconds - -class CacheTests { - private val cache: Cache = CacheBuilder().build() - - @Test - fun getIfPresent() { - cache.put("key", "value") - assertEquals("value", cache.getIfPresent("key")) - } - - @Test - fun getOrPut() { - assertEquals("value", cache.getOrPut("key") { "value" }) - } - - @Test - fun getAllPresent() { - cache.put("key1", "value1") - cache.put("key2", "value2") - assertEquals(mapOf("key1" to "value1", "key2" to "value2"), cache.getAllPresent(listOf("key1", "key2"))) - assertEquals(mapOf("key1" to "value1", "key2" to "value2"), cache.getAllPresent()) - } - - @Ignore // Not implemented yet - @Test - fun putAll() { - cache.putAll(mapOf("key1" to "value1", "key2" to "value2")) - assertEquals(mapOf("key1" to "value1", "key2" to "value2"), cache.getAllPresent(listOf("key1", "key2"))) - } - - @Test - fun invalidate() { - cache.put("key", "value") - cache.invalidate("key") - assertEquals(null, cache.getIfPresent("key")) - } - - @Ignore // Not implemented yet - @Test - fun invalidateAll() { - cache.put("key1", "value1") - cache.put("key2", "value2") - cache.invalidateAll(listOf("key1", "key2")) - assertEquals(null, cache.getIfPresent("key1")) - assertEquals(null, cache.getIfPresent("key2")) - } - - @Ignore // Not implemented yet - @Test - fun size() { - cache.put("key1", "value1") - cache.put("key2", "value2") - assertEquals(2, cache.size()) - } - - @Test - fun maximumSize() { - val cache = CacheBuilder().maximumSize(1).build() - cache.put("key1", "value1") - cache.put("key2", "value2") - assertEquals(null, cache.getIfPresent("key1")) - assertEquals("value2", cache.getIfPresent("key2")) - } - - @Test - fun maximumWeight() { - val cache = CacheBuilder().weigher(399) { _, _ -> 100 }.build() - cache.put("key1", "value1") - cache.put("key2", "value2") - assertEquals(null, cache.getIfPresent("key1")) - assertEquals("value2", cache.getIfPresent("key2")) - } - - @Test - fun expireAfterAccess() = - runTest { - var timeNs = 0L - val cache = CacheBuilder().expireAfterAccess(100.milliseconds).ticker { timeNs }.build() - cache.put("key", "value") - - timeNs += 50.milliseconds.inWholeNanoseconds - assertEquals("value", cache.getIfPresent("key")) - - timeNs += 100.milliseconds.inWholeNanoseconds - assertEquals(null, cache.getIfPresent("key")) - } - - @Test - fun expireAfterWrite() = - runTest { - var timeNs = 0L - val cache = CacheBuilder().expireAfterWrite(100.milliseconds).ticker { timeNs }.build() - cache.put("key", "value") - - timeNs += 50.milliseconds.inWholeNanoseconds - assertEquals("value", cache.getIfPresent("key")) - - timeNs += 50.milliseconds.inWholeNanoseconds - assertEquals(null, cache.getIfPresent("key")) - } -} diff --git a/core/api/jvm/core.api b/core/api/jvm/core.api deleted file mode 100644 index 7a452a0a2..000000000 --- a/core/api/jvm/core.api +++ /dev/null @@ -1,69 +0,0 @@ -public abstract interface annotation class org/mobilenativefoundation/store/core5/ExperimentalStoreApi : java/lang/annotation/Annotation { -} - -public final class org/mobilenativefoundation/store/core5/InsertionStrategy : java/lang/Enum { - public static final field APPEND Lorg/mobilenativefoundation/store/core5/InsertionStrategy; - public static final field PREPEND Lorg/mobilenativefoundation/store/core5/InsertionStrategy; - public static final field REPLACE Lorg/mobilenativefoundation/store/core5/InsertionStrategy; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store/core5/InsertionStrategy; - public static fun values ()[Lorg/mobilenativefoundation/store/core5/InsertionStrategy; -} - -public abstract interface class org/mobilenativefoundation/store/core5/KeyProvider { - public abstract fun fromCollection (Lorg/mobilenativefoundation/store/core5/StoreKey$Collection;Lorg/mobilenativefoundation/store/core5/StoreData$Single;)Lorg/mobilenativefoundation/store/core5/StoreKey$Single; - public abstract fun fromSingle (Lorg/mobilenativefoundation/store/core5/StoreKey$Single;Lorg/mobilenativefoundation/store/core5/StoreData$Single;)Lorg/mobilenativefoundation/store/core5/StoreKey$Collection; -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreData { -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreData$Collection : org/mobilenativefoundation/store/core5/StoreData { - public abstract fun copyWith (Ljava/util/List;)Lorg/mobilenativefoundation/store/core5/StoreData$Collection; - public abstract fun getItems ()Ljava/util/List; - public abstract fun insertItems (Lorg/mobilenativefoundation/store/core5/InsertionStrategy;Ljava/util/List;)Lorg/mobilenativefoundation/store/core5/StoreData$Collection; -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreData$Single : org/mobilenativefoundation/store/core5/StoreData { - public abstract fun getId ()Ljava/lang/Object; -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreKey { -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Collection : org/mobilenativefoundation/store/core5/StoreKey { - public abstract fun getInsertionStrategy ()Lorg/mobilenativefoundation/store/core5/InsertionStrategy; -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Collection$Cursor : org/mobilenativefoundation/store/core5/StoreKey$Collection { - public abstract fun getCursor ()Ljava/lang/Object; - public abstract fun getFilters ()Ljava/util/List; - public abstract fun getSize ()I - public abstract fun getSort ()Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Collection$Page : org/mobilenativefoundation/store/core5/StoreKey$Collection { - public abstract fun getFilters ()Ljava/util/List; - public abstract fun getPage ()I - public abstract fun getSize ()I - public abstract fun getSort ()Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Filter { - public abstract fun invoke (Ljava/util/List;)Ljava/util/List; -} - -public abstract interface class org/mobilenativefoundation/store/core5/StoreKey$Single : org/mobilenativefoundation/store/core5/StoreKey { - public abstract fun getId ()Ljava/lang/Object; -} - -public final class org/mobilenativefoundation/store/core5/StoreKey$Sort : java/lang/Enum { - public static final field ALPHABETICAL Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; - public static final field NEWEST Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; - public static final field OLDEST Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; - public static final field REVERSE_ALPHABETICAL Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; - public static fun values ()[Lorg/mobilenativefoundation/store/core5/StoreKey$Sort; -} - diff --git a/core/build.gradle.kts b/core/build.gradle.kts deleted file mode 100644 index 171a92669..000000000 --- a/core/build.gradle.kts +++ /dev/null @@ -1,14 +0,0 @@ -plugins { - id("org.mobilenativefoundation.store.multiplatform") -} - -kotlin { - - sourceSets { - commonMain { - dependencies { - implementation(libs.kotlin.stdlib) - } - } - } -} diff --git a/core/gradle.properties b/core/gradle.properties deleted file mode 100644 index 1fe16b330..000000000 --- a/core/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -POM_NAME=org.mobilenativefoundation.store -POM_ARTIFACT_ID=core5 -POM_PACKAGING=jar \ No newline at end of file diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/ExperimentalStoreApi.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/ExperimentalStoreApi.kt deleted file mode 100644 index 2191f0934..000000000 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/ExperimentalStoreApi.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.mobilenativefoundation.store.core5 - -/** - * Marks declarations that are still **experimental** in store API. - * Declarations marked with this annotation are unstable and subject to change. - */ -@MustBeDocumented -@Retention(value = AnnotationRetention.BINARY) -@RequiresOptIn(level = RequiresOptIn.Level.WARNING) -annotation class ExperimentalStoreApi diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt deleted file mode 100644 index 23206ad77..000000000 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/InsertionStrategy.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.mobilenativefoundation.store.core5 - -@ExperimentalStoreApi -enum class InsertionStrategy { - APPEND, - PREPEND, - REPLACE, -} diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt deleted file mode 100644 index 3e320a9fe..000000000 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/KeyProvider.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.mobilenativefoundation.store.core5 - -@ExperimentalStoreApi -interface KeyProvider> { - fun fromCollection( - key: StoreKey.Collection, - value: Single, - ): StoreKey.Single - - fun fromSingle( - key: StoreKey.Single, - value: Single, - ): StoreKey.Collection -} diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt deleted file mode 100644 index 2011c4f83..000000000 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreData.kt +++ /dev/null @@ -1,36 +0,0 @@ -package org.mobilenativefoundation.store.core5 - -/** - * An interface that defines items that can be uniquely identified. - * Every item that implements the [StoreData] interface must have a means of identification. - * This is useful in scenarios when data can be represented as singles or collections. - */ -@ExperimentalStoreApi -interface StoreData { - /** - * Represents a single identifiable item. - */ - interface Single : StoreData { - val id: Id - } - - /** - * Represents a collection of identifiable items. - */ - interface Collection> : StoreData { - val items: List - - /** - * Returns a new collection with the updated items. - */ - fun copyWith(items: List): Collection - - /** - * Inserts items to the existing collection and returns the updated collection. - */ - fun insertItems( - strategy: InsertionStrategy, - items: List, - ): Collection - } -} diff --git a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt b/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt deleted file mode 100644 index ab367d564..000000000 --- a/core/src/commonMain/kotlin/org/mobilenativefoundation/store/core5/StoreKey.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.mobilenativefoundation.store.core5 - -/** - * An interface that defines keys used by Store for data-fetching operations. - * Allows Store to fetch individual items and collections of items. - * Provides mechanisms for ID-based fetch, page-based fetch, and cursor-based fetch. - * Includes options for sorting and filtering. - */ -@ExperimentalStoreApi -interface StoreKey { - /** - * Represents a key for fetching an individual item. - */ - interface Single : StoreKey { - val id: Id - } - - /** - * Represents a key for fetching collections of items. - */ - interface Collection : StoreKey { - val insertionStrategy: InsertionStrategy - - /** - * Represents a key for page-based fetching. - */ - interface Page : Collection { - val page: Int - val size: Int - val sort: Sort? - val filters: List>? - } - - /** - * Represents a key for cursor-based fetching. - */ - interface Cursor : Collection { - val cursor: Id? - val size: Int - val sort: Sort? - val filters: List>? - } - } - - /** - * An enum defining sorting options that can be applied during fetching. - */ - enum class Sort { - NEWEST, - OLDEST, - ALPHABETICAL, - REVERSE_ALPHABETICAL, - } - - /** - * Defines filters that can be applied during fetching. - */ - interface Filter { - operator fun invoke(items: List): List - } -} diff --git a/docs/store6/important-defaults.md b/docs/store6/important-defaults.md new file mode 100644 index 000000000..e48a3b605 --- /dev/null +++ b/docs/store6/important-defaults.md @@ -0,0 +1,130 @@ +# Important defaults + +A zero-config `store { fetcher { … } }` (see the [Quickstart](quickstart.md)) already makes a lot +of decisions for you. This page names +every one of them, so you can find out here rather than in production. + +Each line ends in the conformance test that guarantees it. Those tests are the specification — if a +line here and its test ever disagree, the test is right and this page is a bug. + +> **Zero configuration and explicit expert configuration are byte-identical in behavior.** Setting +> the defaults by hand changes nothing observable: both sides produce the same trace and the same +> fetch count (`zeroConfig_and_expertConfig_observeIdenticalDefaults`). One honest limit on that +> guarantee: the equivalence is asserted over persistence, bookkeeper, freshness validator, and idle +> cap. It does not cover telemetry or overlay, which are unset on both sides. + +## Freshness + +See [Freshness policies](/docs/store6/concepts/freshness) for the complete per-call policy contract. + +- **The default is `Freshness.CachedOrFetch`.** An absent key fetches; a resident fresh value is + served without a second fetch (`defaultFreshness_isCachedOrFetch_zeroConfig`). +- **`MaxAge` within its bound serves the resident value without a second fetch** + (`maxAgeWithinBoundServesResidentWithoutSecondFetch`). +- **`MustBeFresh` always fetches**, even against a fresh resident value + (`mustBeFreshRefetchesFreshResident`). +- **`LocalOnly` never fetches.** It serves what is resident + (`localOnlyResidentIgnoresInvalidationForGetAndStream`), and when nothing is resident it fails + `Missing` without a `Loading` frame and without calling the fetcher + (`localOnlyWithoutResidentReportsMissingWithoutLoadingOrFetcherCall`). +- **Stale-while-revalidate is the shape of every refresh.** A stale value is served immediately and + the refresh produces **exactly one** terminal outcome — one fresh `Data`, or one served-stale + `Error`, or one `Revalidated`, never two + (`ac1a_staleWhileRevalidate_successEmitsStaleThenExactlyOneFreshData`, and its `ac1b`/`ac1c`/`ac1d` + siblings). A `get` on a stale resident value serves the stale value and refetches in the + background (`getOnStaleResident_servesStaleThenRefetchesInBackground`). + +## Retry + +- **The engine does not retry your fetcher. Zero retries, zero backoff, at zero configuration.** One + demand cycle invokes the fetcher exactly once, and a failure schedules no background retry — on + the terminalizing `get` path and on a live `stream` collector that survives the failure. A later + call is new demand rather than a continuation of the failed one + (`fetcherFailure_isNotRetried_zeroConfig`, which pins all three). If you want retries, they belong + in your fetcher, where you control the policy. +- **The source-of-truth reader subscription self-heals.** If the reader pipeline drops, the engine + re-subscribes on a fixed internal delay. This is engine behavior, not a knob: **the delay constant + is internal and not contractual**, and no test pins its literal value. + +## Cache and memory + +See [Memory and lifecycle](/docs/store6/concepts/memory-and-lifecycle) for eviction and +store-lifecycle guidance. + +- **In-memory source of truth and in-memory bookkeeper by default.** Nothing is written to disk + until you install persistence (`StoreBuilder.kt:180`, `StoreBuilder.kt:52`). +- **Idle residency is capped at 128 keys.** Quiescent engines park in an idle set bounded by that + cap, and the zero-config cap is the same as an explicit `maxIdleKeys(128)` + (`defaultMaxIdleKeys_matchesExplicit128Cap`, `quiescentKeys_parkInIdle_boundedByMaxIdleKeys`). +- **Eviction touches only quiescent engines.** A key with an active collector or an in-flight fetch + is never evicted, and residency stays bounded under churn + (`churn10kKeyCycles_neverEvictsHeldEngines_andResidencyStaysBounded`, + `activeCollector_pinsEngine_acrossChurn`). +- **Eviction is semantically invisible.** Destroying and recreating an engine preserves per-key + stale marks and namespace watermarks, and still drives the refetch you would have gotten + (`evictedEngine_recreation_semanticallyInvisible`). +- **Invalidation watermarks survive restart and eviction.** A namespace or global invalidation is + observed by a key a fresh store has never seen + (`invalidateNamespace_watermarkIsObservedForKeyUnseenByFreshStore`). +- **The memory cache never diverges from durable truth** (`memoryCache_neverDivergesFromDurableTruth`). + +## Deduplication and single-flighting + +- **N concurrent callers share one fetch.** 50 getters and 50 collectors demanding the same key + produce exactly one fetch, and all 100 observe its outcome + (`ac2_fiftyGettersAndFiftyCollectorsShareOneFetch`). +- **A stream arriving during an in-flight fetch piggybacks it** rather than starting a second one + (same test, plus `cancelledWaiterDoesNotCancelSharedFetch`). +- **Cancelling a waiter does not cancel the shared fetch.** The work commits and the next caller + reuses it (`cancelledWaiterDoesNotCancelSharedFetch`, + `getAfterStreamCommitted_servesResidentValueWithoutRefetch`). + +## Emission + +See [the read contract](/docs/store6/concepts/read-contract) for the complete result-kind and origin +semantics. + +- **Attribution is honest.** A network commit is `Origin.FETCHER` + (`preSubscribedCollectors_waitThroughQueuedAbsentThenDeliverWriterCurrentEcho`); an external + durable change is `Origin.SOT` (`externalWriteReturned_whileGraceHasOldMemory_convergesMemoryThenSot`); + an optimistic write is `Origin.OVERLAY` (`modifyingOverlay_stampsOverlayOrigin`); and a write-handle + `apply` echo is attributed `Origin.SOT` with no additional fetch + (`writeHandle_apply_emitsSotData_withoutFetch`). +- **A slow collector never blocks a fast one, or the engine** + (`slowCollector_doesNotBlockFastCollector_orEngine_andIsBoundedPerCycle`). +- **Conflation is per result kind, and lifecycle signals are never dropped.** A newer value of the + same kind supersedes an older queued one, but another kind never displaces a queued `Loading`, + `Error`, or `Revalidated` (`slowCollector_getsLatestDataAndEveryLifecycleSignalBeforeCompletion`). + Every collector eventually observes the latest row (`everyCollector_eventuallyObservesLatestRow`). +- **A `NotModified` response surfaces as exactly one `Revalidated(age)`** and clears staleness, not + as a redundant `Data` frame (`conditionalRefetch_notModified_emitsOwnerRevalidatedAndClearsStaleness`). +- **A post-clear stream starts absent or loading and never replays pre-clear data** + (`clear_thenNewStreamEmitsLoadingNeverStaleReplay`, + `clearNamespace_thenNewStreamEmitsLoadingNeverPreClearData`). A clear racing an in-flight fetch + cannot resurrect the discarded commit (`clearDuringInFlightFetch_commitDiscarded_noResurrection`). +- **Invalidation reaches live streams**, and survives a 10,000-invalidation burst without losing the + final staleness (`invalidate_activeStream_observesRefetchedData`, + `invalidate_burstOf10k_convergesWithoutLosingFinalStaleness`). + +## Reader grace + +A re-subscribe within a short window after the last collector leaves resumes the existing pipeline +rather than starting over with a fresh `Loading` frame. The behavior is exercised by the suite. The +window's millisecond value is an internal constant, and this page deliberately does not pin it. + +## What is *not* defaulted + +- **A fetcher is required**, and installing a source of truth does not substitute for one. + `store { }` without `fetcher { }`, `fetcherOfResult { }`, or `fetcher(Fetcher)` fails at + build time with a message that says so (`StoreBuilder.kt:176-179`). +- **Telemetry is unset**, and the engine takes a null fast path rather than paying for a no-op sink + (`StoreBuilder.kt:58`). +- **Overlay is unset.** With no overlay installed, nothing is projected onto reads + (`StoreBuilder.kt:61`). + +--- + +*Every test named above lives under +[`store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/`](../../store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/).* + +*Last verified: 2026-08-10 · `main` @ `a6a156e9`, pre-6.0.0-alpha01* diff --git a/docs/store6/invalidate-vs-clear.md b/docs/store6/invalidate-vs-clear.md new file mode 100644 index 000000000..9b35c8c69 --- /dev/null +++ b/docs/store6/invalidate-vs-clear.md @@ -0,0 +1,160 @@ +# Invalidate or clear + +Both make a value go away. They are not interchangeable, and picking the wrong one produces one of +two bugs: a spinner where the user expected content, or stale content where the user expected a +spinner. + +The one-line version: + +- **`invalidate` marks stale.** The value stays, keeps being served, and gets refreshed. +- **`clear` removes.** The value is gone, and the next read starts from nothing. + +## What invalidate does + +`invalidate(key)` marks the value stale without removing it. On return, active streams of that key +have been signaled and will observe refetched data, and the resident value keeps being served as +stale in the meantime. + +Three properties are worth knowing because they are what make it safe to call: + +- **The stale mark is durable.** It survives process restart until a later successful fetch or + revalidation clears it. +- **It is level-triggered monotone state**, so a signal issued during any race window is never lost. + You do not have to reason about whether a fetch was in flight when you called it. +- **A live collector observes the refetch**, not a gap. This holds under load: a burst of 10,000 + invalidations converges without losing the final staleness. + +What the user sees: the content stays on screen, and updates in place when the fetch lands. That is +the stale-while-revalidate shape, and it is what you want for pull-to-refresh, for a "data changed" +push, and for anything where showing the previous answer beats showing nothing. + + + +```kotlin +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult + +class User(val id: String, val name: String) + +class UserKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} + +// Pull-to-refresh: the current value stays on screen while the refresh runs. +suspend fun onPullToRefresh( + store: Store, + key: UserKey, +) { + store.invalidate(key) +} + +// The screen's collector needs no special case. It receives the stale value with +// isStale = true and refreshing = true, then the fresh one. +fun observeUser( + store: Store, + key: UserKey, +): Flow> = store.stream(key) +``` + +## What clear does + +`clear(key)` destructively removes the value. On return the resident value is gone: active streams +observe the absent-value transition (a `Loading` frame) and then refetched data. Removal includes +the configured source-of-truth row and its freshness bookkeeping. + +Two properties matter here: + +- **An in-flight fetch that started before the clear can no longer commit.** Its waiters observe + `StoreError.Missing`. A clear racing a fetch cannot resurrect the discarded value. +- **A post-clear stream never replays pre-clear data.** It starts absent or loading. This is a + guarantee, not a timing accident, and it is what makes clear safe for sign-out. + +What the user sees: the content disappears and a loading state appears. That is correct when the old +value is not just outdated but *wrong to show* — a different user's data, data the current session is +no longer entitled to, a record the server says no longer exists. + + + +```kotlin +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +class User(val id: String, val name: String) + +class UserKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} + +class Document(val id: String, val title: String) + +class DocumentKey( + val organizationId: String, + val documentId: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("documents:$organizationId") + + override fun canonicalId(): String = documentId +} + +// Sign-out: the previous session's data must not be shown again, ever. +suspend fun onSignOut(store: Store) { + store.clearAll() +} + +// A record the server reported as deleted: remove it rather than refreshing it. +suspend fun onRecordDeleted( + store: Store, + key: UserKey, +) { + store.clear(key) +} + +// One tenant's data is no longer valid, the rest is fine. +suspend fun onOrganizationRevoked( + store: Store, + organizationId: String, +) { + store.clearNamespace(StoreNamespace("documents:$organizationId")) +} +``` + +## Choosing + +| You want to say | Use | The user sees | +|---|---|---| +| "This might be out of date, go check" | `invalidate` | Content stays, updates in place | +| "This is no longer valid to show" | `clear` | Content disappears, then loads | +| "Everything for this tenant is suspect" | `invalidateNamespace` | Each affected screen refreshes in place | +| "Everything for this tenant is revoked" | `clearNamespace` | Each affected screen empties, then loads | +| "Sign out" | `clearAll` | Everything empties | + +The test that decides it: **would showing the old value for another few hundred milliseconds be +wrong, or merely imperfect?** Wrong means clear. Imperfect means invalidate. + +## The stale-while-revalidate consequence + +This is where the two diverge most visibly. + +After `invalidate`, the next read serves the stale resident value **immediately** and refreshes in +the background. The refresh produces exactly one terminal outcome: one fresh `Data`, or one +served-stale `Error` if the fetch fails, or one `Revalidated(age)` if the server says nothing +changed. Never two. If the fetch fails, the user still has the old content and an error, rather than +an empty screen. + +After `clear`, there is nothing to serve. The next read is a cold read: `Loading`, then whatever the +fetcher returns. If the fetch fails, the user has an empty screen and an error. + +That asymmetry is the whole decision. `invalidate` degrades gracefully when the network is bad. +`clear` does not, because it cannot — you told it the old value was not safe to show. + +--- + +*Last verified: 2026-07-26 · `main` @ `c4fbaf4`, pre-6.0.0-alpha01* diff --git a/docs/store6/key-design.md b/docs/store6/key-design.md new file mode 100644 index 000000000..b040005a8 --- /dev/null +++ b/docs/store6/key-design.md @@ -0,0 +1,153 @@ +# Keys and namespaces + +Key design is the one thing Store asks you to get right. Everything else has a sensible default. A +key does not, because only you know how your data is shaped. + +It is worth the attention because a `StoreKey` is doing two jobs at once, and they have different +consequences when you get them wrong. + +## The two jobs + + + +```kotlin +public interface StoreKey { + public val namespace: StoreNamespace + public fun canonicalId(): String +} +``` + +**`canonicalId()` is identity.** Two keys with the same namespace and the same canonical id are the +same key: they share one in-flight fetch, one resident value, one stale mark. Two keys with +different canonical ids share nothing. This is the lever that controls deduplication. Get it too +narrow and you fetch the same thing twice under two names. Get it too wide and two different things +collide on one cache entry. + +**`namespace` is the unit of bulk operations.** It is what `invalidateNamespace` and `clearNamespace` +act on, and the durable watermark it carries covers keys the store has never even seen. This is the +lever that controls how much you can invalidate in one call. + +Because identity is a `String`, the rule is simple: **the canonical id must be stable for the +lifetime of the key, and it must contain everything that makes the result different.** If two +requests would return different bytes, their canonical ids must differ. + +## The smallest correct key + + + +```kotlin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +class UserKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} +``` + +One namespace per record type, the record's own identifier as the canonical id. Start here. Most +keys never need to be more than this. + +## When the id needs more than an identifier + +If the same record can come back differently depending on the request, the difference belongs in the +canonical id. A user record fetched with expanded relationships is not the same value as the same +user fetched without them, and it must not overwrite it. + + + +```kotlin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +class UserKey( + val id: String, + val includeOrganization: Boolean = false, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = + if (includeOrganization) "$id+org" else id +} +``` + +Two things to avoid here. Do not put anything in the canonical id that changes between two requests +you *want* deduplicated, such as a timestamp, a request id, or a nonce. And do not put a secret in +it, because the canonical id is a cache key and it will be written to your source of truth. + +## Choosing namespaces + +Namespaces are cheap. Use one per record type as the default, and split further when you want a +smaller blast radius for bulk invalidation. + +The question to ask is: *what do I want to invalidate together?* A pull-to-refresh on a user's +profile screen should invalidate the user, not everything. A sign-out should clear everything. A +"this organization's data changed" push notification is exactly the case for a per-organization +namespace, because it lets one call invalidate the right subset instead of all of it. + + + +```kotlin +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +class Document(val id: String, val title: String) + +class DocumentKey( + val organizationId: String, + val documentId: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("documents:$organizationId") + + override fun canonicalId(): String = documentId +} + +suspend fun onOrganizationChanged( + store: Store, + organizationId: String, +) { + store.invalidateNamespace(StoreNamespace("documents:$organizationId")) +} +``` + +## The payoff + +Once keys are right, the namespace-level operations become the tool you reach for: + +- `invalidate(key)` and `invalidateNamespace(namespace)` mark values stale without removing them. + Active streams are signaled on return and observe refetched data, and the resident value keeps + serving in the meantime. +- `clear(key)`, `clearNamespace(namespace)`, and `clearAll()` destructively remove values. +- The namespace and global watermarks are **durable**, so they cover keys that are not currently + resident and survive process restart. Invalidating a namespace before a key has ever been fetched + still makes that key's first read honest. + +Which of invalidate and clear you want is its own decision, and it has its own guide: +[Invalidate or Clear](invalidate-vs-clear.md). + +## Namespace equality + +Store's internal key registry derives the namespace component of key identity from +`namespace.value`. The `Bookkeeper` contract likewise normalizes that component, and namespace +operations, by the same value. `StoreNamespace` does not override `equals`, so direct equality +between instances remains reference equality. Do not use that result to infer registry or +bookkeeping matches: independently constructed namespaces with the same `.value` address the same +namespace in both. + +## One store or many + +Namespaces partition the maintenance blast radius within one store: use them when records share a +typed Store boundary but need separate `invalidateNamespace` or `clearNamespace` scopes. + +Freshness is not a store-topology choice. Each `stream` or `get` call selects its own `Freshness` +policy, so callers using one store can make different read decisions. + +Use separate stores when domains need independent typed value, failure, and lifecycle boundaries. +This separation does not make operations across stores atomic; no cross-store transaction is part +of the `Store` contract. + +--- + +*Last verified: 2026-08-10 · `main` @ `a6a156e9`, pre-6.0.0-alpha01* diff --git a/docs/store6/quickstart.md b/docs/store6/quickstart.md new file mode 100644 index 000000000..207ec61d7 --- /dev/null +++ b/docs/store6/quickstart.md @@ -0,0 +1,193 @@ +# Quickstart + +> Store 6 is in development and **nothing is published yet**. This page is the shape of the API as +> it stands on `main`; the install coordinates land with 6.0.0-alpha01. + +Store needs two things from you: a **key** that identifies what you want, and a **fetcher** that +knows how to go get it. Everything else — sharing one in-flight request across concurrent callers, +serving what is already resident, tracking staleness, bounding memory — is what Store does with +those two things. + +Here is the whole idea in five lines. + + + +```kotlin +val users = store { + fetcher { key -> FakeApi.getUser(key.id) } +} + +users.stream(UserKey("1")).collect { result -> render(result) } +val user = users.get(UserKey("2")) +``` + +The `store { }` block is verbatim from a module this repository compiles and runs in CI. The last +two lines are shown in their simplest form so the shape is legible. The program below is the exact +one CI executes, and it is where the real `stream` and `get` call sites live. + +## The whole program + +**This exact program compiles and runs on every pull request.** It is the `store6-quickstart` +module, executed by the `./gradlew :store6-quickstart:run` step in +[`.github/workflows/store6.yml`](../../.github/workflows/store6.yml). If it broke, this page +would not be shipping. + +Supporting declarations — the key, the model, and a stand-in service: + + + +```kotlin +package org.mobilenativefoundation.store6.quickstart + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.runBlocking +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store + +/** Identifies a user by the stable identifier used by the example service. */ +private class UserKey( + /** The user identifier passed to the example service. */ + val id: String, +) : StoreKey { + /** The namespace shared by user records in the example store. */ + override val namespace: StoreNamespace = StoreNamespace("users") + + /** Returns the service identifier used to distinguish this user from other users. */ + override fun canonicalId(): String = id +} + +/** A user record returned by the example service. */ +private class User( + /** The stable identifier assigned to this user. */ + val id: String, + + /** The display name returned by the example service. */ + val name: String, +) + +/** Provides deterministic user data for the executable example. */ +private object FakeApi { + /** Returns a user after simulating an asynchronous service call. */ + suspend fun getUser(id: String): User { + delay(100) + return User(id, "User $id") + } +} +``` + +A `StoreKey` gives Store two things: a `namespace`, which groups related records so you can +invalidate or clear them together, and a `canonicalId()`, which distinguishes one record from +another inside that namespace. Key design is the one skill Store asks you to learn, and it has its +own guide: [Keys and Namespaces](key-design.md). + +And `main`: + + + +```kotlin +public fun main(): Unit = + runBlocking { + val users = store { + fetcher { key -> FakeApi.getUser(key.id) } + } + + users.stream(UserKey("1")).take(2).collect { result -> + when (result) { + is StoreResult.Loading -> println("Loading…") + is StoreResult.Data -> println("Data(name=${result.value.name}, origin=${result.origin})") + is StoreResult.Revalidated -> println("Revalidated(age=${result.age})") + is StoreResult.Error -> println("Error(${result.error})") + } + } + println("get: ${users.get(UserKey("2")).name}") + users.close() + } +``` + +## Reading the output + +`stream` gives you a `StoreResult`, and there are exactly four kinds. Handle all four and there is +no fifth case waiting to surprise you: + +- **`Loading`** — demand has been registered and no value is available yet. +- **`Data`** — a value, carrying an `origin` that tells you where it came from (`FETCHER`, `SOT`, + `MEMORY`, `OVERLAY`) and whether it is stale or refreshing. The example prints the origin because + attribution honesty is a contract, not a debugging aid. +- **`Revalidated`** — the server said nothing changed. You get one of these with the resident value's + age, rather than a redundant `Data` frame. +- **`Error`** — the fetch failed. If a stale value was resident, you will have been served it first. + +One detail worth naming so it does not read as magic: **`take(2)` is what ends this program.** +`stream` is an unbounded flow that stays live for as long as you collect it. The example takes the +first two frames — `Loading`, then `Data` — and stops. In an app you collect for the lifetime of the +screen instead, and `close()` the store when you are done with it. + +Continue with [the read contract](/docs/store6/concepts/read-contract) for result and failure +semantics, then [freshness policies](/docs/store6/concepts/freshness) for choosing how each read +uses resident and fetched data. + +## Write path (experimental) + +> **Experimental.** `store6-mutations` is a separate artifact and every public symbol is +> `@ExperimentalStoreApi`. It ships **with** 6.0.0-alpha01 — nothing here is published yet. +> +> **The spelling below is the current API surface.** The module is still experimental — shapes +> can change in any release — but the snippet below matches the implementation. + +Optimistic writes go through a journal, so they survive being offline and survive process death. +You get a mutation store instead of a plain one, and it is a `Store` — everything above still works. + + + +```kotlin +@OptIn(ExperimentalStoreApi::class) // required: the whole module is experimental +val users = mutationStore( + registry = registry, + server = server, + // Restart-safe key recovery is compile-time required. For keys reconstructible from the + // identity pair, the resolver is one line: + keyResolver = MutationKeyResolver { identity -> UserKey(identity.canonicalId) }, + valueCodecVersion = 1, + valueCodec = userJsonCodec, +) { + fetcher { key -> api.load(key) } +} + +users.mutate(key, renameRef, Rename("new name")) // journalled — the only write path +users.drain(key) // push pending intents and adopt each ack +``` + +The flow, end to end: + +1. **Offline enqueue.** `mutate` appends one intent and returns a mutation id. Nothing is pushed. +2. **Optimistic visibility.** `stream(key)` emits `Data(value = optimistic, origin = OVERLAY)`. +3. **Reconnect and acknowledge.** `drain(key)` pushes the pending intents and adopts each ack. +4. **Confirmed.** By the acknowledgement contract, the server's echo becomes the committed value, + attributed `SOT` or `MEMORY`, and the optimistic frame is retired rather than replayed. A stream + opened after the acknowledgement sees the echo. Convergence for a collector that was *already* + active across the acknowledgement is the subject of open engine work and is not yet a behavior + this page will promise. No redundant fetch happens anywhere in this sequence. + +Two properties that are design decisions rather than accidents: + +- **`runtime()` returns `null` on a mutation store, by design.** That withholds the raw write handle, + which is the library-granted way to write around the journal. Every consumer write stays + journalled, and there is no second path that could commit a value the journal never saw. +- **A pending write is `origin == OVERLAY`, not `isStale`.** `isStale` is never set on an overlay + frame, because an optimistic value genuinely is new. Drive a "saving…" indicator off the origin and + narrate the `OVERLAY` → `SOT` flip. See [the stability policy](../../STABILITY.md#9-reading-pending-writes-and-staleness) + for the full consumer guidance, and + note that `get` is unprojected: overlays apply only to `stream`. + +The alpha ships a two-step durable acknowledgement path, which means a crash in the acknowledgement +window leaves a replayable pending intent rather than losing your write, at the cost of the same +push possibly being re-sent. That tradeoff is stated in full in +[the stability policy](../../STABILITY.md#mutations). + +--- + +*Last verified: 2026-08-10 · `main` @ `a6a156e9`, pre-6.0.0-alpha01* diff --git a/docs/superpowers/plans/2026-08-06-store6-alpha01-source-doc-cleanup.md b/docs/superpowers/plans/2026-08-06-store6-alpha01-source-doc-cleanup.md new file mode 100644 index 000000000..2c2c39018 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-store6-alpha01-source-doc-cleanup.md @@ -0,0 +1,376 @@ +# Store6 alpha01 Source Documentation Cleanup Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Every task is also governed by the `authoring:documentation-discipline` and `authoring:code-documentation` skills; read both before executing any task. + +**Goal:** Remove all internal organizational context (issue IDs, decision tags, approval state, personal names, speculative future-work claims) from KDoc and comments in the `store6-*` source tree, and bring the public-surface KDoc of the alpha01 artifacts up to interface-documentation standard, without changing a single executable token. + +**Architecture:** Grep-driven detection sweeps define a red/green cycle per module. Each rewrite preserves behavioral contracts verbatim in meaning while deleting provenance, then proves the documentation-only boundary with `apiCheck` (klib ABI dumps unchanged), `compileKotlinJvm`, `ktlintCheck`, and a manual comment-only hunk review. A final audit pass reviews the public API surface of the alpha01 artifacts against the interface-documentation checklist. + +**Tech Stack:** Kotlin Multiplatform, KDoc/Dokka (v1, `dokkaHtml` per module), Binary Compatibility Validator (klib mode, `api/` dumps), ktlint, spotless. `explicitApi()` is on for all `store6-*` modules via the `org.mobilenativefoundation.store.store6.multiplatform` convention plugin. + +## Global Constraints + +Every task's requirements implicitly include this entire section. + +### Publication boundary and scope + +- In-scope source: `store6-*/src/**/*.kt` (all source sets, including test sources). All of it ships in the public repository and the alpha01 artifacts. +- alpha01 artifact set (from STABILITY.md §"Artifact" table): `store6-core`, `store6-testing`, `store6-sqldelight`, `store6-room`, `store6-compose`, `store6-mutations`, `store6-bom` (no API surface); `store6-devtools` / `store6-devtools-inspector` target alpha02. `store6-mutations-sqldelight` and `store6-mutations-testing` have `api/` dumps and are published. +- The disclosure sweep (Tasks 2–6) covers **all** `store6-*` modules including unpublished ones (`store6-benchmarks`, `store6-quickstart`, `store6-extension-probe`, demo modules). The quality audit (Task 7) covers only the alpha01 artifact set. +- **Never edit anything under any `build/` directory.** `store6-sqldelight/build/borrowedConformance/**` is generated by borrowing `store6-core` test sources; cleaning the core test sources cleans it on the next build. +- Out of scope: README/ROADMAP/STABILITY/docs-site prose (revised separately, on `main` since PR #21), executable code, `api/*.api` dumps (generated — never hand-edit), build scripts. + +### Documentation-discipline digest (binding) + +- Master test: keep an element only when the reader needs it to use, change, operate, or reason about the system correctly. +- **Protected content — a doc pass must not alter:** identifiers, signatures, types, annotations (`@ExperimentalStoreApi`, `@DelicateStoreApi`, `@SubclassOptInRequired(...)`, `@JvmName`, suppressions), expect/actual declarations, source-set placement, imports, code blocks, inline code spans, KDoc `@param`/`@return` names, numbers, units, error names, and **behavioral guarantees**. Comment syntax does not make behavior-bearing content editable. +- **Behavioral guarantees survive verbatim in meaning.** Many flagged comments entangle a real contract with provenance (e.g. a tested invariant introduced by an internal issue). Delete the provenance; keep the contract. If you cannot verify a contract claim against code or tests, keep its meaning unchanged and record it in the inventory as `Unverifiable` — do not delete or "improve" it. +- **Banned in code documentation (universal anti-pattern, not optional):** private tracker IDs and URLs (Linear, `STORE-n`), internal issue numbers (`Issue 021`, `issue 007`), decision/ruling tags (`TD-11`, `RISK-2`, `FS-8`, `RD-1`, `D9`, `D15a`), PR numbers, internal owners and personal names ("Matt signs off"), approval/landing state ("PROVISIONAL pending…", "after issue 007 lands"), rollout status, and team shorthand. +- **Speculative intent is banned.** A sentence describing behavior that does not exist in the current tree ("Issue 024 selects its transactional decorator") is deleted, not rewritten. Document what the extension point does *today*; verify against code before keeping any claim. +- Also remove where encountered in flagged files: obvious narration (restating the adjacent code), hype, invented precision. Do not expand scope to unflagged files for style-only edits (that is Task 7's audit, which is bounded separately). +- Preserve complete existing documentation otherwise. Revise only the evidenced deficiency. +- Prose style: exact technical terms, short direct sentences, periods over semicolons. Repository conventions win over these defaults. + +### Rewrite rubric + +Classify every sweep hit into exactly one class and apply its rule: + +| Class | Signature | Rule | +| --- | --- | --- | +| **P1 tag** | A bare provenance token appended to an otherwise self-contained sentence: `(TD-11)`, `(D15a)`, `(D14/D15a)` | Delete the token (and its parentheses/connector). Keep the sentence unchanged. | +| **P2 governance stamp** | Approval/freeze state with internal referents: `Freeze candidate: … after issue 007 lands and Matt signs off …`, `PROVISIONAL pending Issue 021:` | Delete the stamp sentence or label. The `@ExperimentalStoreApi` annotation plus STABILITY.md already carry the public stability contract; do not restate it per declaration. | +| **P3 speculative intent** | Future-work claims: `Issue 024 selects…`, `so Issue 022 can normalize…`, `lands at Issue 021 (D2)` | Delete the future claim. If the surrounding sentence documents a current, code-verified contract, keep that part self-contained. | +| **P4 provenance-as-rationale** | Rationale expressed only via an internal referent: `parking is produced by Issue 023 over Issue 022's durable rows` | Rewrite the rationale in terms of the components themselves (e.g. name the drain pipeline / journal storage by their class or concept names), verified against code. If the rationale cannot be restated from evidence, delete the rationale and keep the contract. | +| **FP false positive** | e.g. "ruled out" in ordinary prose | Record in inventory as FP. No change. | + +### Worked examples (verbatim from the tree, 2026-08-06) + +**P2 — the freeze-candidate stamp** ([StoreWriteHandle.kt:12](store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreWriteHandle.kt:12) and ~15 sibling occurrences): + +``` +Before: + * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may + * still change until then. + +After: +(line deleted — the declaration's @ExperimentalStoreApi annotation and STABILITY.md's +seam-package freeze-candidate entry already state the public contract) +``` + +**P2 + P1 — entangled facade KDoc** ([MutationStore.kt:40](store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:40)): + +``` +Before: + * PROVISIONAL pending Issue 021: this facade deliberately withholds the raw engine write handle. + * Calling `runtime()` on it returns `null`; [keyEvents] is re-published so advisory access survives + * that narrowing, and it gains no `Rekeyed` variant. + +After: + * This facade deliberately withholds the raw engine write handle. Calling `runtime()` on it + * returns `null`; [keyEvents] is re-published so advisory access survives that narrowing, and it + * gains no `Rekeyed` variant. +``` + +Every contract clause survives; only the governance label is gone. Same file, line 38: `…through the canonical alias table (D15a).` → delete ` (D15a)` (P1). + +**P1 — bare tag** ([TransactionalSourceOfTruth.kt:8](store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt:8)): + +``` +Before: + * Optional atomicity capability for a [SourceOfTruth] (TD-11). Detectable via + +After: + * Optional atomicity capability for a [SourceOfTruth]. Detectable via +``` + +**P3 — speculative intent** ([MutationStoreBuilder.kt:34](store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:34)): + +``` +Before: + * silently substituted (D9). Issue 024 selects its transactional decorator — or reports its + * explicit non-transactional fallback. + +After: + * silently substituted. +``` + +The future-work sentence is deleted entirely. Before keeping any remaining clause, read the surrounding code to confirm it describes current behavior. + +### Detection sweep (the "test") + +The sweep regex, used by every task (`BANNED` below). Run from the repo root: + +```bash +grep -rEn 'TD-[0-9]+|RISK-[0-9]+|STORE-[0-9]+|FS-[0-9]+|RD-[0-9]+|\(D[0-9]+[a-z]?\)|[Ii]ssue [0-9]{2,3}|PROVISIONAL|pending [Ii]ssue|PR #[0-9]+|linear\.app|\bMatt\b|signs? off|sign-off' \ + --include='*.kt' --exclude-dir=build store6-*/src +``` + +A second, classify-only pattern (higher false-positive rate — every hit gets classified in the inventory, FPs are allowed to remain): `ruling|ruled|adopted shape|erratum`. + +Baseline measured 2026-08-06: 65 line hits for the ID classes, 132 for the governance/speculative classes, 32 for personal-name/sign-off, across ~33 non-generated files (24 commonMain, 6 commonTest, 1 hostTest; concentration: store6-core 8 files, store6-mutations 8, store6-room 7, store6-compose 3, store6-testing 3, store6-sqldelight 2, store6-devtools 1, store6-mutations-testing 1). + +### Verification recipe (every rewrite task) + +After editing a module `:M`, all of the following, in order: + +1. `grep` sweep scoped to `M/src` → **0 hits** (recorded FPs excepted). +2. `./gradlew :M:compileKotlinJvm :M:apiCheck :M:ktlintCheck` → `BUILD SUCCESSFUL`. +3. `git diff --stat -- M/api` → empty (KDoc never reaches the klib ABI dump; any diff here means an executable token changed — **stop, revert the hunk, report**). +4. `git diff -- M/src` hunk review: every hunk touches only `/** … */` or `//` content. Any hunk touching code outside comment trivia → stop the affected edit, revert it, record the unresolved boundary in the inventory. +5. KDoc `[links]` you touched still name real declarations in the same source set (spot-check by reading; full Dokka proof runs once in Task 8). + +Proof-strength ceiling: this repo has no token/AST trivia-equivalence prover, so the strongest claim any task may report is **"repository checks plus hunk review"** — never "mechanically proven". + +### Repo conventions + +- Branch for the whole plan: `docs/alpha01-source-doc-cleanup` (created in Task 1). +- Commit style: `docs(): ` (matches existing `feat(mutations):` / `test(core):` history). +- Inventory file: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md`, committed and updated by every task. Format: one table per module — columns `file:line | excerpt | class (P1–P4/FP/Unverifiable) | action taken`. + +--- + +### Task 1: Branch and baseline inventory + +**Files:** +- Create: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` + +**Interfaces:** +- Produces: the working branch and the committed baseline inventory that Tasks 2–8 update. Later tasks assume the branch exists and append to the inventory's per-module tables. + +- [ ] **Step 1: Create the branch** + +```bash +git checkout -b docs/alpha01-source-doc-cleanup +``` + +Expected: `Switched to a new branch 'docs/alpha01-source-doc-cleanup'`. + +- [ ] **Step 2: Capture the red baseline** + +Run the Detection sweep command from Global Constraints, plus the classify-only pattern, and save raw output: + +```bash +grep -rEn 'TD-[0-9]+|RISK-[0-9]+|STORE-[0-9]+|FS-[0-9]+|RD-[0-9]+|\(D[0-9]+[a-z]?\)|[Ii]ssue [0-9]{2,3}|PROVISIONAL|pending [Ii]ssue|PR #[0-9]+|linear\.app|\bMatt\b|signs? off|sign-off' \ + --include='*.kt' --exclude-dir=build store6-*/src | sort > /tmp/doc-sweep-baseline.txt +wc -l /tmp/doc-sweep-baseline.txt +``` + +Expected: on the order of 130–200 lines (baseline of 2026-08-06 measured 65+132+32 hits with overlap between patterns; re-measure, do not assume). + +- [ ] **Step 3: Write the inventory skeleton** + +Create `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` with: the sweep command verbatim, the measured baseline count, and one empty table per module (columns `file:line | class | action`), one section per Task 2–7. Paste the per-module hit lists from the baseline file into the matching sections, unclassified. + +- [ ] **Step 4: Commit** + +```bash +git add docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md +git commit -m "docs: add source-doc cleanup inventory baseline" +``` + +--- + +### Task 2: Delete the freeze-candidate stamp (cross-module, one pattern) + +**Files:** +- Modify: every file matched by `grep -rln 'signs off' --include='*.kt' --exclude-dir=build store6-*/src` — measured 2026-08-06: `store6-core/src/commonMain/.../seam/` (`StoreWriteHandle.kt`, `StoreRuntime.kt`, `Fetcher.kt`, `KeyEvents.kt`, `StoreTelemetry.kt`, `TransactionalSourceOfTruth.kt`, `FreshnessValidator.kt` ×6, `Bookkeeper.kt` ×2), `store6-testing/.../FakeStore.kt` ×4 and two commonTest files, `store6-room/.../RoomSourceOfTruth.kt` ×2, `store6-sqldelight/.../SqlDelightSourceOfTruth.kt`, `SqlDelightBookkeeper.kt`. Re-enumerate at execution time; the stamp text is identical everywhere. +- Modify: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` + +**Interfaces:** +- Consumes: branch + inventory from Task 1. +- Produces: zero `signs off|sign-off|\bMatt\b|issue 007` hits repo-wide; inventory rows classed P2/action=deleted. Task 3 assumes seam files no longer contain the stamp. + +- [ ] **Step 1: Enumerate (red)** + +```bash +grep -rn 'signs off' --include='*.kt' --exclude-dir=build store6-*/src | wc -l +``` + +Expected: >0 (baseline ~15 occurrences across ~14 files). + +- [ ] **Step 2: Delete every stamp instance** + +In each file, delete the full sentence `Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then.` including its `*`-prefixed continuation line where wrapped, leaving the rest of the KDoc block intact. If deleting it leaves an empty KDoc paragraph, remove the orphaned blank ` *` line too. Do not add replacement text (rubric P2: the `@ExperimentalStoreApi` annotation and STABILITY.md carry the contract). + +- [ ] **Step 3: Sweep clean (green)** + +```bash +grep -rEn '\bMatt\b|signs? off|sign-off|issue 007' --include='*.kt' --exclude-dir=build store6-*/src +``` + +Expected: no output, exit code 1. + +- [ ] **Step 4: Verify each touched module** + +```bash +./gradlew :store6-core:compileKotlinJvm :store6-core:apiCheck :store6-core:ktlintCheck \ + :store6-testing:compileKotlinJvm :store6-testing:apiCheck :store6-testing:ktlintCheck \ + :store6-room:compileKotlinJvm :store6-room:apiCheck :store6-room:ktlintCheck \ + :store6-sqldelight:compileKotlinJvm :store6-sqldelight:apiCheck :store6-sqldelight:ktlintCheck +git diff --stat -- store6-core/api store6-testing/api store6-room/api store6-sqldelight/api +``` + +Expected: `BUILD SUCCESSFUL`; empty diff stat. Then review `git diff` per the Verification recipe (comment-only hunks). + +- [ ] **Step 5: Update inventory and commit** + +```bash +git add -A store6-core store6-testing store6-room store6-sqldelight docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md +git commit -m "docs: remove internal sign-off stamp from freeze-candidate KDoc" +``` + +--- + +### Task 3: store6-core remaining internal references + +**Files:** +- Modify: the store6-core files remaining in the inventory after Task 2 (baseline: 8 flagged files; `TransactionalSourceOfTruth.kt` carries `(TD-11)` at line 8 and possibly further tags; enumerate with the sweep scoped to `store6-core/src`). +- Modify: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` + +**Interfaces:** +- Consumes: stamp-free seam files from Task 2. +- Produces: sweep-clean `store6-core/src`; inventory rows classified. Task 6 relies on cleaned commonTest sources here to fix `store6-sqldelight/build/borrowedConformance` at its generation source. + +- [ ] **Step 1: Enumerate (red)** — run the full Detection sweep scoped to `store6-core/src`; paste hits into the inventory section. +- [ ] **Step 2: Classify and rewrite** — apply the rubric per hit. P1 examples: `(TD-11)`. For each P3/P4 hit, read the surrounding declaration and its tests before keeping any claim; mark kept-but-unproven claims `Unverifiable` in the inventory rather than deleting contract meaning. This includes commonTest and hostTest sources. +- [ ] **Step 3: Sweep clean (green)** — full Detection sweep scoped to `store6-core/src`; expected: no output (recorded FPs excepted). +- [ ] **Step 4: Verify** — `./gradlew :store6-core:compileKotlinJvm :store6-core:apiCheck :store6-core:ktlintCheck`; `git diff --stat -- store6-core/api` empty; comment-only hunk review. Also run the module's own tests if any touched comment sat inside a test file: `./gradlew :store6-core:jvmTest` → `BUILD SUCCESSFUL`. +- [ ] **Step 5: Update inventory and commit** — `git commit -m "docs(core): remove internal issue and design-doc references from KDoc"`. + +--- + +### Task 4: store6-mutations + +**Files:** +- Modify (baseline 8 flagged files, re-enumerate): `MutationStore.kt` (facade KDoc lines 36–53 incl. the worked example, plus lines ~294, ~310, ~366), `MutationStoreBuilder.kt` (~34, ~95, ~151), `MutationProtocol.kt` (~368), `MutationEvents.kt` (~293, `TD-8 note:`), `MutatorRegistry.kt` (~17), `MutationSourceOfTruth.kt` (~24), plus any others the sweep lists. +- Modify: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` + +**Interfaces:** +- Consumes: rubric + worked examples (two of the four are from this module). +- Produces: sweep-clean `store6-mutations/src`; inventory rows classified. + +- [ ] **Step 1: Enumerate (red)** — Detection sweep scoped to `store6-mutations/src` (baseline: densest module; expect ~30+ hits). +- [ ] **Step 2: Classify and rewrite** — apply the rubric. This module is where P3/P4 dominate ("Issue 022 can normalize the throw", "parking is produced by Issue 023 over Issue 022's durable rows", "Issue 024's transactional decorator can select…"). For every P4, restate the rationale using the component's real names in this tree (journal storage, drain pipeline, alias table) only after confirming the claim in code; otherwise delete the rationale, keep the contract. **Extra caution:** several comments here encode tested invariants (`runtime()` returning `null` on the facade; the deliberate adopt-then-retire ack ordering on the non-transactional path). Their meaning must survive; find the pinning test before touching the sentence, and record the test's FQN in the inventory `action` column. +- [ ] **Step 3: Sweep clean (green)** — scoped sweep; expected: no output. +- [ ] **Step 4: Verify** — `./gradlew :store6-mutations:compileKotlinJvm :store6-mutations:apiCheck :store6-mutations:ktlintCheck`; `git diff --stat -- store6-mutations/api` empty; comment-only hunk review; `./gradlew :store6-mutations:jvmTest` if test files were touched. +- [ ] **Step 5: Update inventory and commit** — `git commit -m "docs(mutations): make KDoc contracts self-contained, drop internal issue references"`. + +--- + +### Task 5: Adapter modules — store6-room, store6-sqldelight, store6-compose, store6-mutations-sqldelight + +**Files:** +- Modify: remaining flagged files in these four modules (baseline: room 7 files, sqldelight 2, compose 3, mutations-sqldelight per sweep; Task 2 already removed the stamp lines). +- Modify: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` + +**Interfaces:** +- Consumes: cleaned core seam KDoc (adapters cross-link seam types; links must keep resolving). +- Produces: sweep-clean sources for all four modules; inventory rows classified. + +- [ ] **Step 1: Enumerate (red)** — Detection sweep scoped to each of the four `*/src` trees. +- [ ] **Step 2: Classify and rewrite** — rubric as above. Adapters mostly carry P1 tags and P4 rationale referencing core issues; the same verify-before-keep rule applies. +- [ ] **Step 3: Sweep clean (green)** — scoped sweeps; expected: no output. +- [ ] **Step 4: Verify** — `./gradlew :store6-room:compileKotlinJvm :store6-room:apiCheck :store6-room:ktlintCheck :store6-sqldelight:compileKotlinJvm :store6-sqldelight:apiCheck :store6-sqldelight:ktlintCheck :store6-compose:compileKotlinJvm :store6-compose:apiCheck :store6-compose:ktlintCheck :store6-mutations-sqldelight:compileKotlinJvm :store6-mutations-sqldelight:apiCheck :store6-mutations-sqldelight:ktlintCheck`; empty `git diff --stat -- */api`; comment-only hunk review. (If a module lacks the `compileKotlinJvm` task, substitute that module's `compileReleaseKotlinAndroid` and note it in the inventory.) +- [ ] **Step 5: Update inventory and commit** — `git commit -m "docs(adapters): remove internal references from room, sqldelight, compose, mutations-sqldelight"`. + +--- + +### Task 6: Testing/devtools modules, test sources, unpublished modules + +**Files:** +- Modify: remaining flagged files in `store6-testing`, `store6-mutations-testing`, `store6-devtools`, `store6-devtools-inspector`, plus all remaining commonTest/hostTest hits repo-wide, plus any hits in `store6-benchmarks`, `store6-quickstart`, `store6-extension-probe`, `store6-compose-demo`, `store6-devtools-demo`. +- Modify: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` + +**Interfaces:** +- Consumes: all prior module cleanups. +- Produces: the full-tree Detection sweep returns zero (recorded FPs excepted) — the precondition for Task 8's gate. + +- [ ] **Step 1: Enumerate (red)** — full Detection sweep (all `store6-*/src`); everything still listed belongs to this task. +- [ ] **Step 2: Classify and rewrite** — rubric as above. Test-source comments follow the same rules; a test named after or commenting an internal ruling keeps its behavior description and loses the ruling reference. **Do not rename test functions or classes** — identifiers are protected; only comments/KDoc change here. +- [ ] **Step 3: Sweep clean (green)** — full Detection sweep; expected: no output, exit 1. +- [ ] **Step 4: Verify** — `./gradlew :store6-testing:compileKotlinJvm :store6-testing:apiCheck :store6-testing:ktlintCheck :store6-mutations-testing:compileKotlinJvm :store6-mutations-testing:apiCheck :store6-devtools:compileKotlinJvm :store6-devtools:apiCheck` plus `:store6-testing:jvmTest` and `:store6-mutations-testing:jvmTest` (test files were touched); empty `api/` diffs; comment-only hunk review. +- [ ] **Step 5: Update inventory and commit** — `git commit -m "docs: clear internal references from testing, devtools, and test sources"`. + +--- + +### Task 7: Public-surface KDoc quality audit — alpha01 artifacts (audit, then bounded fixes) + +**Files:** +- Modify: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` (audit findings section) +- Modify: only files named by recorded findings (fix step) + +**Interfaces:** +- Consumes: disclosure-clean tree from Tasks 2–6. +- Produces: an audit table (finding classes: Missing / Stale / Contradictory / Duplicated / Unverifiable / Misleading / Unnecessary / Sufficient) over the public declarations of `store6-core`, `store6-mutations`, `store6-testing`, `store6-sqldelight`, `store6-room`, `store6-compose` (~570 public declarations, approx. grep count 2026-08-06); fixes applied for every finding classed Missing, Stale, Contradictory, Misleading, or Unnecessary. + +- [ ] **Step 1: Audit, module by module (read-only).** For each public declaration (explicitApi mode means every intended-public symbol is spelled `public`), check its KDoc against the interface-documentation checklist: purpose beyond the name; inputs/outputs incl. nullability and defaults; errors callers must handle; side effects and lifecycle; ordering/concurrency/idempotency where the type is concurrent (flows, suspend functions: collection/completion/cancellation behavior — do not invent dispatcher or threading guarantees the code doesn't make). Also flag: signature narration (KDoc that restates names and types without use semantics — the finding class is Unnecessary), and the four commonMain files with zero KDoc found on 2026-08-06 (one each in `store6-core`, `store6-room`, `store6-testing`, `store6-devtools-inspector` — check intended-public status first; internal-only files may correctly have none). Record every finding as `file:line | class | evidence | remediation`. +- [ ] **Step 2: Fix recorded findings only.** No opportunistic edits outside the table. Missing → write the smallest complete contract; Unnecessary narration → delete; Stale/Contradictory → correct against code and tests, citing the evidence in the inventory row. +- [ ] **Step 3: Three-pass review** (from documentation-discipline) over the diff: (1) accuracy & protected content — every protected token and contract unchanged; (2) warrant — no unsupported claims or invented precision entered; (3) reader task — a caller can use each revised symbol without reading private internals. +- [ ] **Step 4: Verify** — `./gradlew apiCheck ktlintCheck` at the root scoped to the six modules (or per-module as in prior tasks); all `git diff --stat -- */api` empty; comment-only hunk review. +- [ ] **Step 5: Commit** — `git commit -m "docs: interface KDoc audit fixes for alpha01 artifacts"`. + +--- + +### Task 8: Final gate, Dokka proof, completion report + +**Files:** +- Modify: `docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md` (completion report section) + +**Interfaces:** +- Consumes: everything. +- Produces: the branch ready for PR with a completion report at the required proof strength. + +- [ ] **Step 1: Full sweep gate** + +Run the Detection sweep and the classify-only pattern over all `store6-*/src`. Expected: zero hits for the Detection sweep; every remaining classify-only hit has an FP row in the inventory. + +- [ ] **Step 2: Full verification** + +```bash +./gradlew apiCheck ktlintCheck spotlessCheck +./gradlew :store6-core:dokkaHtml :store6-mutations:dokkaHtml :store6-testing:dokkaHtml :store6-room:dokkaHtml :store6-sqldelight:dokkaHtml :store6-compose:dokkaHtml +``` + +Expected: `BUILD SUCCESSFUL` for both. Dokka failures here mean a broken KDoc `[link]` — fix the link text only, re-run. Note: do **not** rerun any historical CI job as part of this plan; local verification plus the PR's own CI run is the evidence. + +- [ ] **Step 3: Full-branch diff review** + +`git diff main...HEAD -- 'store6-*/src'` — confirm every hunk is comment-only, one final time. `git diff main...HEAD --stat -- '*/api'` — empty. + +- [ ] **Step 4: Write the completion report** into the inventory file: files reviewed/changed; evidence used per kept-contract claim; commands run and results; unresolved `Unverifiable` rows; claims deleted for lack of evidence; proof strength = **"repository checks plus hunk review"** (no mechanical trivia-equivalence proof exists in this repo — do not claim stronger). + +- [ ] **Step 5: Commit and open PR** + +```bash +git add docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md +git commit -m "docs: source-doc cleanup completion report" +git push -u origin docs/alpha01-source-doc-cleanup +gh pr create --title "docs: alpha01 source documentation cleanup" --body "$(cat <<'EOF' +Removes internal organizational context (issue IDs, decision tags, approval state, personal names, speculative future-work claims) from all store6-* KDoc and comments, and applies interface-doc audit fixes to the alpha01 public surface. Documentation-only: all api/ dumps byte-identical; apiCheck, ktlint, spotless, and per-module dokkaHtml green. Inventory and completion report: docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-review notes + +- Every requirement of the request (internal-context removal, quality pass, alpha-readiness gate) maps to a task: disclosure → Tasks 2–6, quality → Task 7, proof → Task 8. +- Baseline counts and file lists are measured (2026-08-06), labeled as such, and every task re-enumerates at execution time instead of trusting them. +- The plan writes no rewrite text it has not verified: the four worked examples quote the current tree verbatim; all other rewrites are governed by the rubric plus a verify-before-keep rule, because pre-authoring 190+ rewrites without reading each surrounding declaration would bake in unverified contract claims. +- Known risk, mitigated: contract-bearing sentences entangled with provenance. Mitigation is the `Unverifiable` class (keep meaning, record) plus the apiCheck/empty-`api/`-diff/hunk-review boundary proof in every task. + +--- + +## Amendment A1 (2026-08-06, during execution): Sweep v2 + +Task 3 surfaced internal-referent families the Detection sweep does not match: design-doc section references (`engine-design §7`, `design §`), acceptance/test/criteria tags (`TEST-1`, `C-01`, `AC-3`, `OQ-5`), design-table rows (`row-7/8`), and bare zero-padded issue numbers (`017 residual-deadline repair`). Ruling: + +- **Sweep v2 (hard, zero-hit gate):** `engine-design|design §|§[0-9]+|\bTEST-[0-9]+\b|\bC-[0-9]{2}\b|\bAC-[0-9]+\b|\bOQ-[0-9]+\b|\brow-7/8\b` +- **Sweep v2 (classify-only, FPs recorded):** `\b0(0[1-9]|1[0-9]|2[0-9])\b|\bR[0-9]\b|\bT2E\b` +- **Task 3b (inserted):** apply v2 to `store6-core/src` (the only module whose v1 pass is complete). Includes the `seam/TransactionalSourceOfTruth.kt` second KDoc paragraph ("The row-7/8 direct-write optimization requires an extension-owned coordinated decorator…"): P3 speculative intent plus internal referent on published KDoc — delete the paragraph; the interface's own contract sentences stay. Also `StoreConformanceTest.kt` "(validator arrives in 004)" and remaining tagged comments. +- **Tasks 4–6:** each module gate = Detection sweep v1 + v2, both to zero (recorded FPs excepted). **Task 8:** full-tree gate = v1 + v2. +- **Known out-of-charter residue** (protected executable content; recorded in the completion report, not edited): internal shorthand inside string literals (e.g. an `error(...)` message in `SourceOfTruthHydrationRaceTest.kt`) and test-fixture data (`User("42", "Matt")`). +- `ktlintCheck` is a no-op for `store6-*` modules (root `build.gradle.kts` returns early); formatting assurance for this plan rests on `spotlessCheck` in Task 8 plus hunk review. Verification recipes' ktlint mentions are harmless but vacuous. diff --git a/docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md b/docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md new file mode 100644 index 000000000..080086c1a --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-store6-doc-cleanup-inventory.md @@ -0,0 +1,971 @@ +# Store6 alpha01 Source Documentation Cleanup — Inventory + +This inventory is the shared baseline and per-task tracking file for the `docs/alpha01-source-doc-cleanup` branch. Tasks 2-7 update the tables below in place as they classify and resolve hits; Task 8 appends the completion report. + +## Detection sweep (verbatim) + +Run from the repo root: + +```bash +grep -rEn 'TD-[0-9]+|RISK-[0-9]+|STORE-[0-9]+|FS-[0-9]+|RD-[0-9]+|\(D[0-9]+[a-z]?\)|[Ii]ssue [0-9]{2,3}|PROVISIONAL|pending [Ii]ssue|PR #[0-9]+|linear\.app|\bMatt\b|signs? off|sign-off' \ + --include='*.kt' --exclude-dir=build store6-*/src +``` + +A second, classify-only pattern (higher false-positive rate — every hit gets classified in the inventory, FPs are allowed to remain): `ruling|ruled|adopted shape|erratum`. + +Baseline capture command (this file's row set = Detection sweep ∪ classify-only pattern, sorted and deduplicated): + +```bash +grep -rEn 'TD-[0-9]+|RISK-[0-9]+|STORE-[0-9]+|FS-[0-9]+|RD-[0-9]+|\(D[0-9]+[a-z]?\)|[Ii]ssue [0-9]{2,3}|PROVISIONAL|pending [Ii]ssue|PR #[0-9]+|linear\.app|\bMatt\b|signs? off|sign-off' \ + --include='*.kt' --exclude-dir=build store6-*/src | sort > detection-sweep.txt +grep -rEn 'ruling|ruled|adopted shape|erratum' \ + --include='*.kt' --exclude-dir=build store6-*/src | sort > classify-only-sweep.txt +cat detection-sweep.txt classify-only-sweep.txt | sort -u > sweep-baseline.txt +wc -l sweep-baseline.txt +``` + +## Measured baseline counts (2026-08-06) + +- Detection sweep (ID classes + governance/speculative + personal-name/sign-off, combined single regex): **235 line hits**. +- Classify-only pattern (`ruling|ruled|adopted shape|erratum`): **30 line hits**. +- Overlap between the two patterns (same file:line matched by both): **3 line hits**. +- Combined, sorted, deduplicated baseline (235 + 30 − 3): **262 line hits**. +- Raw baseline file: `.superpowers/sdd/sweep-baseline.txt` (262 lines, this repo's worktree; not committed — regenerate with the commands above if needed). + +Per-module breakdown of the 262 combined hits: + +| module | hits | +| --- | --- | +| `store6-mutations` | 157 | +| `store6-core` | 48 | +| `store6-room` | 23 | +| `store6-testing` | 16 | +| `store6-benchmarks` | 5 | +| `store6-compose` | 4 | +| `store6-sqldelight` | 4 | +| `store6-compose-demo` | 2 | +| `store6-devtools` | 2 | +| `store6-mutations-testing` | 1 | +| **total** | **262** | + + +Note on the plan's stated expectation: the plan text (Global Constraints and Task 1 Step 2) was written from an earlier measurement ("65+132+32" / "130-200 lines") and explicitly says "re-measure, do not assume." The counts above are the actual 2026-08-06 re-measurement using the verbatim commands; they supersede the plan's placeholder numbers. + +--- + +## Task 2 — Delete the freeze-candidate stamp (cross-module, one pattern) + +Scope per the plan: every file matched by `grep -rln 'signs off' --include='*.kt' --exclude-dir=build store6-*/src`. The table below is the broader baseline subset matching Task 2's own sweep-clean check pattern (`\bMatt\b|signs? off|sign-off|issue 007`) — **44 hits** across 9 modules, wider than the plan's originally-guessed module list. Each row here is also present in its owning module's full table in the Task 3/5/6 sections below (intentional overlap — Task 2 resolves this specific stamp pattern first, cross-module; the owning module's task then re-sweeps for what remains). + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt:21` | * collections — issue 007's OQ-5 explicitly deferred grace tuning (and retry-backoff shape) to | P3 | Deleted future-work clause ("issue 007's OQ-5 ... to first 016 data; this benchmark is that data's source"); kept the READER_PIPELINE_GRACE_MILLIS contract (verified: `store6-core/.../StoreLifecycle.kt:15`) and the raw-churn sentence. | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt:26` | * Closed-store behavior (finalized by issue 007): calling this on a closed store fails the | P2 | Deleted "(finalized by issue 007)" landing-state parenthetical (worked-example pattern from resolution); "Closed-store behavior:" contract sentence otherwise unchanged. | +| `store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt:20` | * exact `Store.stream` seam they call. Close semantics were finalized by issue 007; the close | P2 | Deleted "Close semantics were finalized by issue 007; " clause; kept the OQ-3/ABI message-text contract. | +| `store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt:12` | // Process-scoped store on the landed bounded-registry engine (issue 007): idle key engines | P1 | Deleted bare "(issue 007)" tag mid-sentence; bounded-registry/LRU contract unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruth.kt:20` | * Canonical-key cells are intentionally unbounded until issue 007 adds their lifecycle policy. | P3 | Deleted "until issue 007 adds their lifecycle policy"; kept verified "intentionally unbounded" contract (no eviction/bound in the class body). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt:23` | * FS-1's O(1)-per-collector bound and closes the lifecycle-signal bound deferred to issue 007. | P2 | Deleted "deferred to issue 007"; kept the FS-1/lifecycle-signal-bound contract. The `FS-1` tag on this line is untouched, out of Task 2's pattern scope — left for Task 3's own sweep. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt:33` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim (worked example) + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt:105` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim (worked example) + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Fetcher.kt:15` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence + preceding blank line, keeping the single blank separator before `@param` intact. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:15` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim (worked example) + orphaned blank KDoc line (`FreshnessContext` doc). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:30` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim + orphaned blank KDoc line (`FreshnessValidator` interface doc). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:42` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim + orphaned blank KDoc line (`FetchPlan` sealed interface doc). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:50` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim + orphaned blank KDoc line (`FetchPlan.Skip` doc). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:57` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim + orphaned blank KDoc line (`FetchPlan.Fetch` doc). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:66` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim + orphaned blank KDoc line (`FetchPlan.Conditional` doc). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/KeyEvents.kt:26` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 | Deleted 2-line wrapped stamp + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Overlay.kt:39` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes | P2 | Deleted 2-line wrapped stamp + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt:15` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreRuntime.kt:13` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 | Deleted 2-line wrapped stamp + preceding blank line, keeping the blank separator before `@param` intact. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreTelemetry.kt:25` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 | Deleted 2-line wrapped stamp + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreWriteHandle.kt:12` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 | Deleted 2-line wrapped stamp (the brief's worked example) + preceding blank line, keeping the blank separator before `@param` intact. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt:24` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence + preceding blank line, keeping the blank separator before `@param` intact. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/WallClock.kt:13` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 | Deleted stamp sentence verbatim + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-devtools/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/StoreDevtoolsEvent.kt:13` | * decided: values never cross this seam. The seam remains a freeze candidate and sign-off is held. | P2 | Deleted "The seam remains a freeze candidate and sign-off is held." sentence; kept the v0-vocabulary/wire-format sentence. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruth.kt:41` | * Cells are intentionally unbounded, matching the core default's posture until the issue 007 | P3 | Deleted "until the issue 007 lifecycle policy applies here"; kept "intentionally unbounded, matching the core default's posture" contract. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeper.kt:41` | * This seam remains FREEZE CANDIDATE pending Matt signature. | P2 | Deleted stamp variant sentence + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruth.kt:212` | * Freeze candidate: issue 007 has landed; the seam freezes only after Matt signs the prepared | P2 | Deleted 2-line stamp variant ("Freeze candidate: issue 007 has landed ... sign-off package.") + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruth.kt:213` | * sign-off package. | P2 | Same edit as :212 (continuation line of the same stamp sentence). | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeepingEntity.kt:14` | * This surface is a seam freeze candidate pending Matt's signature. | P2 | Deleted stamp variant sentence + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeper.kt:35` | * This seam remains FREEZE CANDIDATE awaiting Matt signature. | P2 | Deleted stamp variant sentence + orphaned blank KDoc line; rest of KDoc unchanged. | +| `store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruth.kt:62` | * Seam status: FREEZE CANDIDATE awaiting Matt signature; never frozen. | P2 | Deleted stamp variant sentence + preceding blank line, keeping the blank separator before `@param` intact. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:40` | * Invalidation implements Decision #37 (Matt, 2026-07-20): it is a stale-mark only and never | P2 | Deleted "Decision #37 (Matt, 2026-07-20): " provenance prefix; kept "invalidation is a stale-mark only and never consumes a script" contract. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:59` | * The seam consumed here is a FREEZE CANDIDATE, not frozen: freeze sign-off remains held until | P2 | Deleted stamp sentence ("The seam consumed here is a FREEZE CANDIDATE ... Matt signs off."); kept the following close()-behavior sentences (same edit as :60/:63). | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:60` | * issue 007 lands and Matt signs off. [close] is synchronous and idempotent. Active collectors are | P2 | Same edit as :59 (continuation of the stamp sentence; "[close] is synchronous..." onward kept). | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:63` | * text were finalized by issue 007 against the engine's close lifecycle. | P2 | Rewrote "text were finalized by issue 007 against the engine's close lifecycle" to "text are pinned against the engine's close lifecycle" (same edit as :59/:60), keeping the contract that these details are pinned/verified against engine behavior. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:274` | * Close semantics finalized by issue 007. | P2 | Deleted "Close semantics finalized by issue 007." line; kept "Closes this fake synchronously and idempotently." | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:437` | * Decision #37 (ruled by Matt, 2026-07-20): invalidate is a stale-mark only, the engine's | P2 | Deleted "Decision #37 (ruled by Matt, 2026-07-20): " provenance prefix; kept the epoch-bump-analog contract. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:487` | // Finalized by issue 007: core keeps STORE_CLOSED_MESSAGE internal by design (FS-5 — | P2 | Deleted "Finalized by issue 007: " prefix; kept the STORE_CLOSED_MESSAGE/FS-5 rationale. The `FS-5` tag on this line is untouched, out of Task 2's pattern scope — left for Task 6's own sweep. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:32` | * There is no invalidate-divergence row: Decision #37 (Matt, 2026-07-20) aligned the fake to the | P2 | Deleted "Decision #37 (Matt, 2026-07-20)" provenance subject; rewrote to "the fake is aligned to..." keeping the stale-mark-only/next-demand contract. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:191` | // THE Decision #37 alignment pin (ruled by Matt, 2026-07-20): with NO active demand, | P2 | Deleted "THE Decision #37 alignment pin (ruled by Matt, 2026-07-20): " prefix; kept "With NO active demand, invalidate defers..." contract (rest of the comment unchanged). | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:375` | // Finalized by issue 007: pins verified against StoreCloseLifecycleTest in store6-core. | P2 | Deleted "Finalized by issue 007: " prefix; kept "Pins verified against StoreCloseLifecycleTest in store6-core." | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:386` | // Finalized by issue 007: pins verified against StoreCloseLifecycleTest in store6-core. | P2 | Deleted "Finalized by issue 007: " prefix (second, distinct occurrence in `close_cancelsActiveCollectors`); kept "Pins verified against StoreCloseLifecycleTest in store6-core." | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/UserViewModelSampleTest.kt:45` | fake.enqueueFetchValue(key, User("42", "Matt")) | FP | No change. `"Matt"` is a test-fixture `User.name` string literal (protected code/data value), not documentation or a governance/provenance reference — coincidental regex match. Flagged in the task report as a deliberate deviation from the literal "gate returns zero" wording, since altering test data is out of this task's charter. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/UserViewModelSampleTest.kt:49` | assertEquals("Matt", assertIs(awaitItem()).name) | FP | No change (same rationale as :45 — asserts against the same test-fixture value). | + +--- + +## Task 3 — store6-core remaining internal references + +Scope: all baseline hits in `store6-core/src` (full per-module list; Task 2 above resolves the `signs off` stamp subset first, then Task 3 sweeps `store6-core/src` again for what remains). + +Task 3 result: both sweeps return literal zero for `store6-core/src` (main Detection pattern and the classify-only `ruling|ruled|adopted shape|erratum` pattern). No FP rows were needed in this module. + +Two same-comment referents that the sweep regex does not match were removed with their flagged lines, because they sit inside the same comment block and leaving them would have produced incoherent prose: the `017 residual-deadline repair:` prefix (12 occurrences, always line 1 of the two-line Turbine-deadline comment) and `(017 post-merge)` in `KeyEnginePlanningTest.kt:289`. + +Out-of-sweep referents observed in `store6-core/src` and **deliberately left unchanged** (no sweep pattern matches them, so no later task will catch them either — flagged here for a controller ruling, candidates for a Task 8 addendum): `engine-design §7` (`KeyRegistry.kt:12`, `KeyRegistry.kt:112`, `StoreLifecycle.kt:11`), `Per engine-design R3` (`StoreResultFlows.kt:25`), `The row-7/8 direct-write optimization` (`TransactionalSourceOfTruth.kt:12`), `THE 001 acceptance test … TEST-1 emission-sequence seed` (`StoreConformanceTest.kt:26`), `C-01/C-02 seed` (`StoreConformanceTest.kt:64`), `the 001 get-posture … (validator arrives in 004)` (`StoreConformanceTest.kt:89`), `C-04/C-05/C-06 shape` (`SourceOfTruthConformanceTest.kt:33`, `:72`, `:110`), `TEST-7` (`StoreTelemetryTest.kt:114`), `005 must not emit Revalidated` (`SourceOfTruthHydrationRaceTest.kt:97`), `AC-3 (TEST-1)` (`StoreInvalidationConformanceTest.kt:28`), `C-12 seed` (`StoreInvalidationConformanceTest.kt:285`). + + +#### `store6-core` (48 hits at baseline) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruth.kt:20` | * Canonical-key cells are intentionally unbounded until issue 007 adds their lifecycle policy. | P3 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt:21` | * bookkeeper, so a recreated engine is semantically identical (issue 006's hydration | P4 | Rewrote the provenance rationale as the mechanism: `(issue 006's hydration stamping)` -> "hydration restamps freshness from the bookkeeper's persisted status". Verified against `KeyEngine.hydrateFromSot` (KeyEngine.kt:2147-2184: reads `bookkeeper.status(key)`, derives `hydratedMeta` and `staleEpochAtCommit`). Recreation-identity claim pinned by `org.mobilenativefoundation.store6.core.StoreEvictionConformanceTest.evictedEngine_recreation_semanticallyInvisible` and `org.mobilenativefoundation.store6.core.StoreEvictionConformanceTest.memoryCache_neverDivergesFromDurableTruth`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt:27` | * - Creation still runs [verifyStableCanonicalId] once per residency (FS-6). | P1 | Deleted the ` (FS-6)` token; sentence otherwise unchanged. Claim verified at KeyRegistry.kt:162 (`newEngine` calls `verifyStableCanonicalId`) and pinned by `org.mobilenativefoundation.store6.core.PublicSurfaceTest.unstableCanonicalId_failsFastNamingTheFix`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt:23` | * FS-1's O(1)-per-collector bound and closes the lifecycle-signal bound deferred to issue 007. | P1 | Task 2 removed "deferred to issue 007"; this task replaced `FS-1's O(1)-per-collector bound` with `an O(1)-per-collector bound`. Both surviving claims pinned by `org.mobilenativefoundation.store6.core.internal.ConflateLatestDataTest.blockedCollector_queueBoundedAcrossManyRevalidationCycles` (kind-bounded queue) and `org.mobilenativefoundation.store6.core.internal.ConflateLatestDataTest.slowCollector_getsLatestDataAndEveryLifecycleSignalBeforeCompletion` (lifecycle-signal bound). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ValueEnvelope.kt:14` | * FS-6 conservative posture: the value reports `isStale = true`, age zero, and never | P1 | Deleted the `FS-6 ` token from "the FS-6 conservative posture"; the contract (`isStale = true`, age zero, never satisfies demand without a revalidation) is unchanged. Pinned by `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.cachedOrFetch_prePopulatedSot_streamServesSotBeforeRevalidation` (asserts `durable.isStale`) and `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.cachedOrFetch_hydratedRow_servesThenRevalidatesExactlyOnce`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt:105` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt:33` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Fetcher.kt:15` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:15` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:30` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:42` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:50` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:57` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:66` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/KeyEvents.kt:26` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Overlay.kt:39` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt:15` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreRuntime.kt:13` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreTelemetry.kt:25` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreWriteHandle.kt:12` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail (brief's worked example). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt:24` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt:8` | * Optional atomicity capability for a [SourceOfTruth] (TD-11). Detectable via | P1 | Deleted the ` (TD-11)` token; sentence otherwise unchanged (brief's worked example). The "engine never assumes it / no silent non-atomic default" clause is verified by absence: `grep -rn 'is TransactionalSourceOfTruth' store6-core/src` returns no engine-side check; the only implementors are `RoomSourceOfTruth` and `SqlDelightSourceOfTruth`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/WallClock.kt:13` | * Freeze candidate: this surface freezes only after issue 007 lands and Matt signs off; shapes may still change until then. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/EmissionSequenceConformanceTest.kt:242` | // T2E ruling: a cold-baseline 304 commits ObsoleteRevalidation and legally | P1 | Deleted the `T2E ruling:` attribution label; the contract sentence survives verbatim in meaning (rewrapped). Pinned by `org.mobilenativefoundation.store6.core.internal.KeyEnginePlanningTest.coldBaselineNotModified_hydratedBeforeCommit_classifiesObsoleteAndReplans` and by the enclosing test's own third-call arm. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/EmissionSequenceConformanceTest.kt:319` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FreshnessPolicyConformanceTest.kt:490` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/KeyEngineSourceOfTruthRaceTest.kt:648` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/PublicSurfaceTest.kt:15` | /** The FS-6 detector: an unstable canonicalId fails fast and names the fix. */ | P1 | `The FS-6 detector:` -> `Detector fixture:`; the rest of the fixture KDoc is unchanged. The behavior it labels is pinned by `org.mobilenativefoundation.store6.core.PublicSurfaceTest.unstableCanonicalId_failsFastNamingTheFix` (same file). | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderGenerationRecoveryTest.kt:134` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthBindingConformanceTest.kt:2073` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthCancellationConformanceTest.kt:537` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:353` | // FS-6 + hydration: unknown provenance serves and triggers exactly one revalidation. | P1 | `FS-6 + hydration:` -> `Hydration:`; claim unchanged. It labels, and is pinned by, `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.cachedOrFetch_hydratedRow_servesThenRevalidatesExactlyOnce`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:394` | // FS-1: persisted truth participates in startup before its revalidation can overwrite it. | P1 | Deleted the `FS-1: ` prefix and capitalized; claim unchanged. It labels, and is pinned by, `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.cachedOrFetch_prePopulatedSot_streamServesSotBeforeRevalidation`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:489` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:269` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:35` | expectNoEvents() // live, not completed (FS-1) | P1 | Deleted the ` (FS-1)` token; the inline comment `// live, not completed` still describes the adjacent `expectNoEvents()` assertion in `org.mobilenativefoundation.store6.core.StoreConformanceTest.coldStream_noCachedValue_emitsLoadingThenDataFromFetcher`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:41` | // (b1) fetcher throws -> stream emits Error and stays live (FS-5: stream never throws) | P1 | `(FS-5: stream never throws)` -> `(the stream never throws)`; the claim survives verbatim in meaning and is pinned by the test it labels, `org.mobilenativefoundation.store6.core.StoreConformanceTest.fetcherThrows_streamEmitsErrorAndStaysLive`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:55` | // (b2) fetcher throws -> get throws StoreException carrying StoreError.Fetch (FS-2/FS-5) | P1 | Deleted the ` (FS-2/FS-5)` token; the label still names the contract, pinned by `org.mobilenativefoundation.store6.core.StoreConformanceTest.fetcherThrows_getThrowsStoreException`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt:318` | assertTrue(exception.message!!.contains("test/1")) // FS-5: which key | P1 | Deleted the `FS-5: ` prefix (`// which key`); the comment still annotates its own `assertTrue(exception.message!!.contains("test/1"))` assertion in `org.mobilenativefoundation.store6.core.StoreInvalidationConformanceTest.clearDuringInFlightFetch_commitDiscarded_noResurrection`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt:319` | assertTrue(exception.message!!.contains("clear")) // FS-5: what happened | P1 | Deleted the `FS-5: ` prefix (`// what happened`); same enclosing test and same self-pinning assertion pattern as :318. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt:655` | // 006 fenced-clear ruling: an already-active pipeline may queue one duplicate | P1 | `006 fenced-clear ruling:` -> `Fenced clear:` (internal issue number and ruling language removed; the mechanism name `fenced clear` is repository vocabulary, see KeyRegistry.kt:23-26 "double-sweep-under-fence"). The two-sentence contract is unchanged and is pinned by the enclosing test `org.mobilenativefoundation.store6.core.StoreInvalidationConformanceTest.clearNamespace_activeLocalOnlyStreamObservesMissingWithoutRefetch`, which bounds queued pre-clear replays at 1 and then asserts Loading followed by Missing. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt:825` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRevalidationConformanceTest.kt:102` | // T2E ruling: a cold-baseline 304 commits ObsoleteRevalidation and legally | P1 | Deleted the `T2E ruling:` attribution label; the contract sentence survives verbatim in meaning (rewrapped). Pinned by `org.mobilenativefoundation.store6.core.internal.KeyEnginePlanningTest.coldBaselineNotModified_hydratedBeforeCommit_classifiesObsoleteAndReplans` and by the enclosing test's own third-call arm. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRevalidationConformanceTest.kt:224` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRevalidationConformanceTest.kt:34` | // T2E ruling: a cold-baseline 304 commits ObsoleteRevalidation and legally | P1 | Deleted the `T2E ruling:` attribution label; the contract sentence survives verbatim in meaning (rewrapped). Pinned by `org.mobilenativefoundation.store6.core.internal.KeyEnginePlanningTest.coldBaselineNotModified_hydratedBeforeCommit_classifiesObsoleteAndReplans` and by the enclosing test's own third-call arm. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRuntimeTest.kt:156` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEnginePlanningTest.kt:289` | // T2E ruling (017 post-merge): a 304 that launched against a null residence baseline but | P1 | Deleted the `T2E ruling (017 post-merge):` attribution label and rewrapped; all three contract clauses (obsolete launch snapshot, not an adapter-contract violation, classify ObsoleteRevalidation and replan once) survive verbatim in meaning. Pinned by the test it labels, `org.mobilenativefoundation.store6.core.internal.KeyEnginePlanningTest.coldBaselineNotModified_hydratedBeforeCommit_classifiesObsoleteAndReplans` (asserts "the cold-baseline 304 self-heal replans exactly once"). | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/OverlayProjectionProtocolTest.kt:1740` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted the `(D0, PR #15)` token and, in the same two-line comment, the leading `017 residual-deadline repair:` attribution; rewrapped to two lines. The rationale (Turbine's 3s default nesting inside the 25s shadow) is unchanged and verified: Turbine 1.2.0 `DEFAULT_TIMEOUT = 3000.milliseconds` (coroutines.kt:26) against the adjacent `TEST_TIMEOUT = 25.seconds` / `TURBINE_DEADLINE = 30.seconds`. | + +--- + +## Task 3b — store6-core, Sweep v2 + +Inserted by plan Amendment A1. Task 3 cleared `store6-core/src` against Sweep v1; this task clears the same module against the widened Sweep v2, and discharges every referent Task 3 recorded above as "deliberately left unchanged" (the paragraph at the head of the Task 3 section). + +Sweep v2 patterns, scoped to `store6-core/src`, `--include='*.kt' --exclude-dir=build`: + +- **hard (zero-hit gate):** `engine-design|design §|§[0-9]+|\bTEST-[0-9]+\b|\bC-[0-9]{2}\b|\bAC-[0-9]+\b|\bOQ-[0-9]+\b|\brow-7/8\b` — **14 hits before, 0 after.** +- **classify-only (FPs may remain):** `\b0(0[1-9]|1[0-9]|2[0-9])\b|\bR[0-9]\b|\bT2E\b` — **8 hits before, 2 after** (both recorded FP below). `T2E` matched nothing (Task 3 had already removed the `T2E ruling:` labels). + +Two source lines matched both patterns (`StoreResultFlows.kt:25`, `StoreConformanceTest.kt:26`), so 22 rows cover 20 distinct lines across 10 files. Class counts over the 20 distinct lines: **P1 12, P3 4, P4 2, FP 2, Unverifiable 0** (Task 6, part B item 5 relabeled `StoreTelemetryTest.kt:114` from P1 to P3 — see that row below). `file:line` is the pre-edit (enumeration-time) location. Sweep v1 re-run after these edits still returns zero for `store6-core/src`. + +#### `store6-core` — hard pattern (14 hits) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt:12` | * Structure (engine-design §7, reconciled with the landed registry): | P1 | Deleted the whole parenthetical (design-doc referent plus its landing-state qualifier `reconciled with the landed registry`), leaving `* Structure:`. The bullet list it introduces is untouched; its claims are pinned by `org.mobilenativefoundation.store6.core.internal.KeyRegistryTest.release_atZeroRefs_parksQuiescentEngineInIdle`, `.idleOverflow_evictsEldestOnly`, `.sweep_retainsEngines_andReleasesAfterAction` and `.counters_createdMinusDestroyed_equalsResident`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt:112` | // release re-runs this check; leaving it active is the engine-design §7 shape. | P4 | Rationale was expressed only via the design-doc referent. Restated from the code: `; leaving it active is the engine-design §7 shape.` -> `, so it deliberately stays active until then.` Verified at KeyRegistry.kt:113 (`if (!handle.engine.isQuiescentForIdle()) return@withLock emptyList()` returns before `active.remove(id)`). Pinned by `org.mobilenativefoundation.store6.core.internal.KeyRegistryTest.fetchResidencyHook_pinsEngineAcrossCallerRelease` and `.fetchJob_pinsResidencyAfterLastWaiterCancels_untilCommitSettles`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt:25` | * Per engine-design R3, a pathological fetch-error storm cannot grow a collector's buffer because | P1 | Deleted the `Per engine-design R3, ` prefix and capitalized; paragraph rewrapped, wording otherwise identical. Also clears this line's classify-only `R3` hit. Pinned by `org.mobilenativefoundation.store6.core.internal.ConflateLatestDataTest.blockedCollector_queueBoundedAcrossManyRevalidationCycles`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidator.kt:43` | * The zero-configuration AC-6 seed policy table. | P1 | Deleted `AC-6 seed` (the acceptance-criterion tag and the internal `seed` shorthand that binds to it), leaving `* The zero-configuration policy table.` The `DefaultFreshnessValidator` table itself and the following clamping sentence are unchanged; pinned by `org.mobilenativefoundation.store6.core.internal.FreshnessValidatorTest` (whole class, one test per `Freshness` arm) and `org.mobilenativefoundation.store6.core.StoreDefaultsPinTest.defaultFreshness_isCachedOrFetch_zeroConfig`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreLifecycle.kt:11` | /** Default bound on quiescent engine residency (engine-design §7; StoreBuilder.maxIdleKeys). */ | P1 | Deleted the `engine-design §7; ` referent; kept the `StoreBuilder.maxIdleKeys` cross-reference, verified to exist at StoreBuilder.kt:63 (`private var maxIdleKeys: Int = DEFAULT_MAX_IDLE_ENGINES`) and StoreBuilder.kt:119. The `128` value is untouched and pinned by `org.mobilenativefoundation.store6.core.StoreZeroConfigEquivalenceTest` (explicit `maxIdleKeys(128)` equivalence arms). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt:12` | * The row-7/8 direct-write optimization requires an extension-owned coordinated decorator | P3 | Pre-ruled: deleted the entire second KDoc paragraph (10 lines) — a design-table referent (`row-7/8`) plus a prescription of an extension-side decorator protocol, on published `@ExperimentalStoreApi` KDoc. Kept every contract sentence of the interface itself: the first paragraph (optional atomicity capability, `sot is TransactionalSourceOfTruth` detection, engine never assumes it, no silent non-atomic default), the trailing `StoreWriteHandle.confirmFresh` sentence, and both `@param` lines. See the Task 3b concerns note below: the deleted protocol *is* implemented, by the unpublished `store6-extension-probe`, whose own KDoc already documents it in terms of its own components. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:33` | // C-05 shape: values arriving via fetch commit are attributed FETCHER. | P1 | Deleted the `C-05 shape: ` prefix and capitalized; the claim is unchanged and is pinned by the test it labels, `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.originHonesty_fetchCommit_emitsFetcher` (asserts `Origin.FETCHER`). | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:72` | // C-06 shape: external data is delivered as SOT/stale before its one active-demand revalidation. | P1 | Deleted the `C-06 shape: ` prefix and capitalized; the claim is unchanged and is pinned by the test it labels, `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.originHonesty_externalSotWrite_emitsSotToActiveStream` (asserts `Origin.SOT`, `isStale`, and exactly two fetch calls). | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:110` | // C-04 shape: the memory fast path serves without waiting for the pipeline, stamped MEMORY. | P1 | Deleted the `C-04 shape: ` prefix and capitalized; the claim is unchanged and is pinned by the test it labels, `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.originHonesty_memoryFastPath_reStampsMemory` (asserts `Origin.MEMORY`). | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreTelemetryTest.kt:114` | // Allocation-count measurement is deferred to store6-benchmarks (TEST-7). | P3 | Relabeled (Task 6, part B item 5 — was misclassed P1; the edit itself is P3-shaped, replacing the future/deferral framing `is deferred to` with `lives in`, not a bare-tag deletion). Deleted the `(TEST-7)` tag; also corrected the stale future framing `is deferred to` -> `lives in`, verified against `store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt` (`org.mobilenativefoundation.store6.benchmarks.TelemetryAllocationProbe.residentServe_callerThreadAllocationDelta_reported`), which already performs that measurement and cross-references `StoreTelemetryTest.kt:114`. One line replaced one line, so that cross-reference's line number still resolves. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:26` | // (a) THE 001 acceptance test — cold stream: Loading then Data(origin=FETCHER). TEST-1 emission-sequence seed. | P1 | Deleted both internal referents (`001`, `TEST-1 emission-sequence seed`), leaving `// (a) the cold-stream acceptance test: Loading then Data(origin=FETCHER)` in the lowercase, no-trailing-period style of the sibling `(b1)`/`(c)` labels. Also clears this line's classify-only `001` hit. Pinned by the test it labels, `org.mobilenativefoundation.store6.core.StoreConformanceTest.coldStream_noCachedValue_emitsLoadingThenDataFromFetcher`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:64` | // (c) single-flight smoke: two concurrent collectors, one fetcher invocation. C-01/C-02 seed. | P1 | Deleted the trailing ` C-01/C-02 seed.` token; the label is otherwise unchanged and is pinned by the test it labels, `org.mobilenativefoundation.store6.core.StoreConformanceTest.twoConcurrentCollectors_singleFetcherInvocation` (asserts `1` fetcher call across two collectors). | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt:28` | // AC-3 (TEST-1): an active stream signaled by invalidate observes refetched data. | P1 | Deleted the `AC-3 (TEST-1): ` prefix and capitalized; the claim is unchanged and is pinned by the test it labels, `org.mobilenativefoundation.store6.core.StoreInvalidationConformanceTest.invalidate_activeStream_observesRefetchedData`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt:285` | // C-12 seed: clear during an in-flight fetch discards the commit; no resurrection. | P1 | Deleted the `C-12 seed: ` prefix and capitalized; the claim is unchanged and is pinned by the test it labels, `org.mobilenativefoundation.store6.core.StoreInvalidationConformanceTest.clearDuringInFlightFetch_commitDiscarded_noResurrection`. | + +#### `store6-core` — classify-only pattern (8 hits) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt:25` | * Per engine-design R3, a pathological fetch-error storm cannot grow a collector's buffer because | P1 | `R3` (design-doc requirement tag). Same line and same edit as the hard-pattern row above — see it for detail and pinning test. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreLifecycle.kt:19` | * to the benchmarks-informed cycle (016) — see decisions/007-memory-boundedness.md. | P3 | Future-work claim carrying an internal issue number and a dangling internal doc path (`decisions/007-memory-boundedness.md` does not exist anywhere in the tree — `find` for `*memory-boundedness*` returns nothing, and there is no `decisions/` directory). Deleted `; tuned backoff is deferred to the benchmarks-informed cycle (016) — see decisions/007-memory-boundedness.md`, collapsing the block to `/** Fixed defensive delay before retrying a failed reader subscription. */`. No contract removed: the surviving sentence plus the untouched `100L` value are the whole documented fact. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:223` | // R7: resubscribe after clear may duplicate, never lose; cleared value never replays. | P1 | Deleted the `R7: ` design-doc requirement tag and capitalized; both clauses are unchanged and are pinned by the test they label, `org.mobilenativefoundation.store6.core.SourceOfTruthConformanceTest.resubscribeAfterClear_duplicatesNotLosses`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:26` | // (a) THE 001 acceptance test — cold stream: Loading then Data(origin=FETCHER). TEST-1 emission-sequence seed. | P1 | `001` (bare zero-padded issue number). Same line and same edit as the hard-pattern row above — see it for detail and pinning test. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:89` | // (d) pins the 001 get-posture: a resident value is served without a refetch (validator arrives in 004) | P3 | Pre-ruled. Deleted the future-work claim `(validator arrives in 004)` and replaced the internal referent `the 001 get-posture` with self-contained phrasing read off the test: `// (d) pins get's posture: a resident value is served without a refetch`. The behavioral half of the label was already self-contained and is unchanged. Pinned by the test it labels, `org.mobilenativefoundation.store6.core.StoreConformanceTest.getAfterStreamCommitted_servesResidentValueWithoutRefetch` (asserts the resident `"v1"` and `1` total fetch call). | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthHydrationRaceTest.kt:97` | is StoreResult.Revalidated -> error("005 must not emit Revalidated.") | FP | Executable string, out of doc-pass charter. Pre-ruled protected content: the `005` sits inside an `error(...)` argument in the `when` arm of `org.mobilenativefoundation.store6.core.SourceOfTruthHydrationRaceTest.externalAbsentObservedDuringHydration_neverResurrectsSnapshot`. No change. Recorded as known out-of-charter residue. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceConformanceTest.kt:15` | * work for issues 010/011. | P4 | Scope note whose rationale existed only as internal issue numbers. Restated in terms of the components: `; on-disk/process durability remains adapter work for issues 010/011.` -> `, not on-disk or cross-process durability, which the persistence adapter modules cover.` Verified: this fixture uses `InMemoryBookkeeper` and an in-memory SoT, so it cannot prove on-disk durability, and the on-disk counterpart exists as `org.mobilenativefoundation.store6.sqldelight.SqlDelightDurableMaintenanceTest.invalidate_markIsObservedByFreshStoreUsingSharedCollaborators` (same test name, real driver) plus `org.mobilenativefoundation.store6.room.RoomStoreSubstitutionConformanceTest`. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEnginePlanningTest.kt:3440` | "the exact R1 row must reach collector delivery before confirmFresh", | FP | Executable string, out of doc-pass charter. The `R1` sits inside the `assertTrue(...)` failure message of `org.mobilenativefoundation.store6.core.internal.KeyEnginePlanningTest.activeExactRowMappedBeforeConfirmFresh_overlayRetireRevealsConfirmedValue`. No change. Recorded as known out-of-charter residue. | + +Task 3b concerns, recorded for the controller: + +1. **The brief's premise for the `TransactionalSourceOfTruth.kt` paragraph deletion is factually wrong in one respect, though the ruling still holds.** The brief states the paragraph "prescribes a decorator protocol nothing in the current tree implements". The tree does contain that implementation: `store6-extension-probe/src/commonMain/kotlin/org/mobilenativefoundation/store6/extensionprobe/CoordinatedTransactionalSourceOfTruth.kt:48`, exercised by `org.mobilenativefoundation.store6.extensionprobe.CoordinatedTransactionalSourceOfTruthTest`. The deletion was still executed as ruled, and remains correct on the rubric for reasons independent of that premise: the paragraph documents an *extension-side* protocol on a *core* interface's published KDoc, it carries the banned `row-7/8` design-table referent, and the protocol is already documented accurately where it is implemented — the probe's own class KDoc (lines 27-45) covers per-key gating, one-transaction write-and-retire, post-commit `apply`/`confirmFresh`, collection restart and authoritative recapture, single retirement signal, and rollback, and states the `confirmFresh` caveat as "does not rely on `confirmFresh` as an observation path". Note that `store6-extension-probe` is an unpublished module, so nothing published now documents this protocol; if the controller wants it published, the probe's KDoc is the accurate source to promote. +2. **The retained `confirmFresh` sentence is now unanchored.** `* \`StoreWriteHandle.confirmFresh\` alone is not an observation mechanism.` was kept per the brief (it is a real contract clause, pinned by `org.mobilenativefoundation.store6.core.internal.KeyEnginePlanningTest.activeExactRowMappedBeforeConfirmFresh_overlayRetireRevealsConfirmedValue`, where `confirmFresh` is followed by `observer.expectNoEvents()`). With the paragraph that motivated it gone, it now reads as a standalone remark about a different type inside `TransactionalSourceOfTruth`'s KDoc. It was promoted to its own paragraph rather than deleted, because deleting it would drop a behavioral guarantee. A Task 7 audit may prefer to move it onto `StoreWriteHandle.confirmFresh` itself, whose current KDoc says only that active streams "may observe one data re-emission". +3. **Unflagged landing-state residue left in place:** `KeyRegistry.kt:23` still reads "preserving the landed double-sweep-under-fence semantics". No sweep pattern matches it and it was not a v2 hit, so it was left unedited rather than expanding scope; flagged as a Task 7/8 candidate. + +--- + +## Task 4 — store6-mutations + +Scope: all baseline hits in `store6-mutations/src`, widened to the full Sweep-v1 + Sweep-v2 union (Detection sweep v1 hard + v1 classify-only + Amendment A1 v2 hard + v2 classify-only), deduplicated by `file:line`. The densest module in the repo. + +Row-set command (run from the repo root, scoped to this module): + +```bash +# v1 hard: the Detection sweep regex verbatim from the top of this file, scoped to the module +grep -rEn '' --include='*.kt' --exclude-dir=build store6-mutations/src | sort > m-v1.txt +grep -rEn 'engine-design|design §|§[0-9]+|\bTEST-[0-9]+\b|\bC-[0-9]{2}\b|\bAC-[0-9]+\b|\bOQ-[0-9]+\b|\brow-7/8\b' \ + --include='*.kt' --exclude-dir=build store6-mutations/src | sort > m-v2.txt +grep -rEn 'ruling|ruled|adopted shape|erratum' --include='*.kt' --exclude-dir=build store6-mutations/src | sort > m-c1.txt +grep -rEn '\b0(0[1-9]|1[0-9]|2[0-9])\b|\bR[0-9]\b|\bT2E\b' --include='*.kt' --exclude-dir=build store6-mutations/src | sort > m-c2.txt +cat m-v1.txt m-v2.txt m-c1.txt m-c2.txt | sort -u | wc -l # 269 +``` + +Measured at the start of Task 4: v1 hard **136**, v2 hard **15**, v1 classify-only **21**, v2 classify-only **137**; deduplicated union **269 rows** across 29 files (11 commonMain, 17 commonTest, 1 jvmTest). The 157 rows the Task 1 baseline recorded for this module are a subset (v1 hard + v1 classify-only only); the other 112 rows are the Sweep-v2 additions required by Amendment A1 and are added below. + +Post-pass state: v1 hard **0**, v2 hard **1** (a string literal in executable code — see `MutationRestartWalkingTest.kt:100`), v1 classify-only **1** (assertion-message string literal), v2 classify-only **4** (three occurrences of the number `1,024`, one test-key string literal). Every survivor has an `FP` row below. + +Class counts: **P1 171 · P2 2 · P3 60 · P4 31 · FP 6 · Unverifiable 0** (270 rows = 269 measured sweep hits + 1 non-sweep P2 added at review). + +#### `store6-mutations` (270 rows: 157 Task 1 baseline + 112 Sweep-v2 additions + 1 non-sweep P2 added at review) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationBookkeeper.kt:21` | * inaccessible internal default is never substituted (D9). Certified against | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:54` | /** The single in-memory attempt generation every 021 push transmits; merges are 023's (D2). */ | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:64` | /** Stable machine detail for a resolver that returned null during global drain (D14). */ | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:67` | /** Stable machine detail for a resolver whose returned pair mismatched the request (D14). */ | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:70` | /** Stable machine detail for a resolver that threw a non-cancellation failure (D14). */ | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:76` | /** Stable machine detail for a keyed drain whose aliased terminal key failed to resolve (D14). */ | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:119` | // retained here — ordered base capture reads [bookkeeper]; 024's transactional selection | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:125` | // conflicts is stored for 023. Defaults exist only for direct engine construction in | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:142` | // R-0 §1's stable installation identity, in-memory form: one fixed string per engine is the | P1 | P1: deleted `R-0 §1`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:143` | // ruled 021 shape; 022 owns durable client rows. | P4 | P4: `one fixed string per engine is the ruled 021 shape; 022 owns durable client rows.` -> `one fixed string per engine, persisted on the durable client row and stamped into every push, attempt, and failure.` Verified: `MutationClientRecord.clientId` and the `clientId = clientId` stamping sites. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:168` | // In-memory effect snapshots captured before first push (D8/R-0 §7); never executed at 021. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:173` | // In-memory execution bookkeeping for truthful inspection (D3/R-0 §3). All of it is | P1 | P1: deleted `(D3/R-0 §3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:174` | // rewritten over durable records at 022/023. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:206` | // 022/023 rebuild routing over durable records. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:231` | // R-0 §1's contiguous locally retired prefix, in-memory form, advertised on pushes (D15a). | P1 | P1: deleted `(D15a)`, `R-0 §1`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:250` | /** The advisory lifecycle bus republished by the facade; 023 owns causal emission. */ | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:702` | * One idempotent keyed foreground pass (D12): captures the unprojected confirmed base through | P1 | P1: deleted `(D12)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:704` | * retry or backoff. A ruled pre-ack codec/projection failure parks that head and continues its | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:706` | * resolves the terminal alias identity before calling this (D15a); a mid-pass activation | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:729` | * One idempotent global foreground pass (D12): enumerates durable identities from the | P1 | P1: deleted `(D12)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:731` | * (D14), and continues past identities that fail to resolve after parking exactly one owned | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:900` | * The normalized in-memory drain failure carriers recorded so far (D3/D14/D15a in 021 form): | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:902` | * `StoreError` is retained; 022/023 own the durable failure rows and the parking these | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:926` | * Snapshot rows for every durable identity in durable client-sequence order (D3): the | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:946` | * Durably parked intents only (D3). Always empty at 021: parking is 023's transition over | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:947` | * 022's durable rows, and the walking skeleton never fakes it. The normalized failure | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:970` | /** The terminal identity for [identity] under the active alias edges (D15a). */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:1052` | * The suspending-facade resolution door (D14): one attempt, then the sanctioned | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:1071` | * The keyed-drain resolution door (D14): a failed terminal resolution parks one owned durable | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:1265` | // A journalled row can never be PARKED or RETIRED at 021; PENDING is the total | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. Pin for the retained claim (now `MutationEngine.kt:1261-1262`): a parked row never reaches the pending snapshot because `publishDurablePark` calls `journal.retire(identity, entry.mutationId)` at `MutationEngine.kt:2551`, and `MutationJournal.retire` filters the entry out of the identity's snapshot at `MutationJournal.kt:313`. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:1421` | * Alias interaction (D15a): the pass drains the durable-client-sequence prefix that existed | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:1427` | * key's pass; 023 owns the durable park transition that halt previews. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:1651` | /** Protected 021 path for direct, codec-less engine constructions in module tests. */ | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:1730` | // pass replays the same immutable generation (D2). Only a non-cancellation | P1 | P1: deleted `(D2)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:2741` | // Cross-namespace acknowledgement rejection retains its separately ruled posture. | P4 | P4: `Cross-namespace acknowledgement rejection retains its separately ruled posture.` -> `A cross-namespace rejection deliberately halts without parking.` Verified against the surrounding `recordDurableAckProtocolRejection` branch (retarget/cycle/retry-mismatch park; cross-namespace halts). | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3695` | * behind it (D12). Ties (direct journal appends in module tests use the default sequence) | P1 | P1: deleted `(D12)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3770` | * The ordered base-capture loop (Shared invariants; R1-18). A present value accepts the | P1 | P1: deleted `R1-18`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3815` | // Overlay.apply terminalizes the key's projected streams permanently (008 contract). | P1 | P1: deleted `(008 contract)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3828` | * Builds the immutable in-memory attempt generation (D2, R-0 §4): stable client identity and | P1 | P1: deleted `(D2, R-0 §4)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3860` | * durable steps 2 and 4 are 022/023's). | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3933` | // The codec-less 021 preview retries by retransmitting the same generation. Retain | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3950` | * unrepresentable (D15a). Issue 022 lands tombstone storage and hydration; the ack/clear and | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3951` | * activation transitions remain 023/024-owned. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3981` | * transactional coordination is 024's (R1-23); restart rehydration is 022's. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:4023` | /** Advances the in-memory contiguous retired prefix (D15a); gaps hold the high-water. */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:4036` | * (D8/R-0 §7). A throwing `stales` function is contained exactly like a throwing projector — | P1 | P1: deleted `(D8/R-0 §7)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:4037` | * ephemeral poison, no transport — and halts this key's pass. 021 never executes effects. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:4067` | /** The captured (never executed) effect snapshot for a mutation; 022 owns durability. */ | P4 | P4: `022 owns durability` -> `the durable rows are [durableEffectsSnapshot]`. Verified: `durableEffectRows` is the executed set (`resumeDurableEffectsPending`), `effectSnapshots` is the in-memory capture read only by `MutationEffectsTest`. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:4096` | /** A library-owned immutable snapshot of captured metadata fields (D2). */ | P1 | P1: deleted `(D2)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEvents.kt:13` | * Read-only, in-process advisory mutation telemetry (D4). | P1 | P1: deleted `(D4)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEvents.kt:268` | * A non-cancellation checkpoint transport, protocol, or persistence failure (D12). Client-scoped | P1 | P1: deleted `(D12)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEvents.kt:293` | * TD-8 note: a `MutableSharedFlow` configured with [BufferOverflow] is legal advisory plumbing; | P1 | P1: deleted the `TD-8 note:` label and recapitalized; both design-constraint clauses (legal advisory plumbing, Channels/actors banned as protocols) and the whole emission contract survive verbatim. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:15` | * The total public mapping of every nonterminal active execution phase (D3). | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:30` | /** The broad, append-only classification of a normalized mutation failure (R-0 §6). */ | P1 | P1: deleted `R-0 §6`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:45` | * A normalized, restart-safe failure record (D3). | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:48` | * contract: [detail] is at most 128 UTF-8 bytes and [message] at most 1,024 UTF-8 bytes, each | FP | FP: matched only by the classify-only `\b0(0[1-9]|1[0-9]|2[0-9])\b` pattern via the number `1,024` (the message byte budget). No change; the number is protected content. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:62` | /** Sanitized human-readable diagnostic; at most 1,024 UTF-8 bytes. */ | FP | FP: same `1,024` byte-budget number. No change. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:128` | * A truthful snapshot of one nonterminal active intent (D3). | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:165` | * A durably parked intent (D3). Dead letters contain only parked entries; parking is legal only | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:205` | * An ephemeral projection-failure report carrying the exact local `Throwable` (D3). | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:34` | /** R-0 §7's effect kind, in-memory form; 022 owns the durable stable names. */ | P4 | P4: `R-0 §7's effect kind, in-memory form; 022 owns the durable stable names.` -> `The in-memory invalidation-effect kind; durable names are `storage.MutationEffectKind`.` Verified: `storage/MutationJournalRecords.kt` defines that public enum. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:41` | * One normalized in-memory invalidation-effect target (R-0 §7's immutable snapshot shape). | P1 | P1: deleted `R-0 §7`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:43` | * 021 captures these before first push and never executes them; execution, dispositions, and | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:44` | * durability are 022/023/024's. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:54` | * records (D8): every key is normalized to its full identity pair; ordering is namespace effects | P1 | P1: deleted `(D8)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:87` | * R-0 §3's execution phase vocabulary, in-memory form; 022 owns the durable stable names. | P4 | P4 + P3: `R-0 §3's execution phase vocabulary` -> `The execution phase vocabulary in its in-memory form; the durable stable names are `storage.MutationExecutionPhase`.` The whole `At 021 ... none of which 021 fakes` paragraph was deleted as stale (all phases now have producers); the total-mapping contract it referenced is documented verbatim on `toPendingStateOrNull` below. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:89` | * At 021 the engine's foreground pass truthfully produces only `UNPREPARED`, `READY`, | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:90` | * `INFLIGHT`, and `ACKED` (plus removal on retirement). `REFRESH_REQUIRED` needs 023's conflict | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:91` | * pipeline, `EFFECTS_PENDING` needs 022/023's effect execution, and `PARKED` needs 023's parking | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:92` | * transaction — none of which 021 fakes. The total public mapping is nevertheless frozen here so | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:93` | * inspection shapes are proven against the ruled vocabulary (D3, R-0 §3). | P1 | P1: deleted `(D3, R-0 §3)`, `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:127` | * [createdAtEpochMillis] the durable enqueue stamp (R-0 §2); the engine allocates both at | P1 | P1: deleted `R-0 §2`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:185` | * The durable identities that currently hold pending intents, in first-enqueue order (D12): | P1 | P1: deleted `(D12)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:437` | /** The 021-compatible default journal, now implemented by the public in-memory storage seam. */ | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:442` | // Cache-fronted canonical alias routing (D15a). | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:445` | /** Stable machine detail for a canonical target in a different namespace (D15a). */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:448` | /** Stable machine detail for a second canonical target claimed for an aliased source (D15a). */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:451` | /** Stable machine detail for a canonical target whose chain reaches back to its source (D15a). */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:454` | /** Stable machine detail for a generation retry acknowledging a different canonical target (D15a). */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:459` | * The lifecycle of one alias edge (D15a): `PENDING` between validated acknowledgement receipt and | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:469` | /** One normalized same-namespace full-pair redirect: source identity to target identity (D15a). */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:516` | /** The outcome of validating one acknowledged canonical target at ack receipt (D15a). */ | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:545` | * transactional retirement composition remain later slices. Deferred proofs: 022 | P3 | P3: deleted `tombstone activation orchestration and transactional retirement composition remain later slices. Deferred proofs: 022 ...; 023 ...` and `Tombstone generations and high-water interaction are R1-21's 022/023/024 tests; 021 records no tombstones.` Verified stale: `MutationEngine` inserts, activates, replaces, and hydrates `MutationKeyTombstoneRecord`s, and both named tests exist. Kept the commit-before-publish and restart-hydration contracts plus a plain statement that tombstone state is modeled separately. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:546` | * `MutationJournalContractTest.kt::aliasEdgesAndActivation_roundTripAcrossRestart`; 023 | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:548` | * Tombstone generations and high-water interaction are R1-21's 022/023/024 tests; 021 records | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:558` | // redirect is also a protocol failure. 022 owns the durable receipt row. | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournal.kt:579` | * persist or publish, an optional pending redirect edge (D15a): | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:16` | * (D13). Push, acknowledgement, conflict, attempt, and adoption carriers contain non-null | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:48` | * components verbatim (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:84` | * decoders remain until the corresponding rows are safely retired and pruned (D7). | P1 | P1: deleted `(D7)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:111` | * The result of a pure registered invalidation function `(key, args) -> StaleSet` (D8). | P1 | P1: deleted `(D8)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:129` | * The library-owned capture carrier handed to the optional precondition selector (D2). | P1 | P1: deleted `(D2)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:169` | * The immutable, library-built transport request for one attempt generation (D2). | P1 | P1: deleted `(D2)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:262` | * Backend coherence obligation for confirmed deletion (D13), certified by returning | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:272` | * retention (D15b). | P1 | P1: deleted `(D15b)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:285` | * The backend's acknowledgement of one pushed generation (D13). | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:289` | * library alone constructs pushes, retirements, identities, inspection rows, and failures (D11). | P1 | P1: deleted `(D11)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:303` | * must return the same canonical target or the intent parks as a protocol violation (D15a). | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:322` | * Backend coherence obligation (D13): Every fetch begun after an Absent acknowledgement returns | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:334` | * The library-built retirement checkpoint request (D15b). | P1 | P1: deleted `(D15b)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:351` | * The consumer-built confirmation of a retirement checkpoint (D15b). | P1 | P1: deleted `(D15b)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:368` | * persisted server-confirmed prefix. Returns the validated new confirmed prefix. Issue 023 | P4 | P4: `Issue 023 normalizes the thrown failure as `MutationFailureKind.PROTOCOL`.` -> `The engine normalizes ...`. Verified at `MutationEngine.flushRetirementCheckpoint`, which catches `validateRetirementAck` and calls `emitCheckpointFailure(kind = MutationFailureKind.PROTOCOL, detail = "retire-ack-invalid")`. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:388` | * Library-side exact-pair resolution validation (D14): the engine calls this on every resolver | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocol.kt:412` | * The explicit outcome of a consumer merge hook after a precondition conflict (D2). | P1 | P1: deleted `(D2)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruth.kt:24` | * engine; core's inaccessible internal default is never substituted, and Issue 024 can select and | P3 | P3 + P1: deleted `and Issue 024 can select and report its explicit non-transactional fallback because this retained default is visible to it (D9)`; kept the forwarded-exact-instance and never-substituted contract plus the whole contract-kit certification list. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruth.kt:26` | * (D9). Certified against `SourceOfTruthContractKit`: | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:38` | * and routes every key-taking operation through the canonical alias table (D15a). | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:40` | * PROVISIONAL pending Issue 021: this facade deliberately withholds the raw engine write handle. | P2 | P2 stamp `PROVISIONAL pending Issue 021:` deleted (worked example). Every contract clause survives verbatim: the withheld write handle, `runtime()` returning `null`, re-published [keyEvents] with no `Rekeyed` variant, and the close ordering. Pinned by `org.mobilenativefoundation.store6.mutations.MutationAckPathTest.rawWriteHandleUnreachableThroughFacade` (asserts `assertNull(users.runtime())` and `assertNotNull(bare.runtime())`) and re-asserted inside `org.mobilenativefoundation.store6.mutations.MutationsWalkingSkeletonTest.offlineMutation_projectsOverlay_thenAckLandsConfirmedWithoutRefetch`. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:66` | * Observes retrieval state and values for the terminal canonical identity of [key] (D15a). | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:68` | * Alias liveness contract (D14): before resolving, the stream snapshots the mutation-owned | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:177` | * Returns the value for the terminal canonical identity of [key] (D15a); one resolution | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:179` | * (D14). This read is never projected by the overlay; overlays apply only to [stream]. | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:190` | * Marks the terminal canonical identity of [key] stale (D15a); one resolution attempt, | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:191` | * throwing a `StoreResults.conversionError`-backed [StoreException] on failure (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:199` | * Destructively removes the value for the terminal canonical identity of [key] (D15a); one | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:201` | * failure (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:250` | * Runs one idempotent, scheduler-agnostic global foreground pass (D12): every durable | P1 | P1: deleted `(D12)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:252` | * [MutationKeyResolver] with exact-pair validation (D14). An identity that fails to resolve | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:270` | * Aliases are followed as durable identity pairs only (D14): this inspection never | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:283` | * identities, in durable client-sequence order (D3). Retired history never appears. | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:292` | * Returns the durably parked intents (D3). Dead letters contain only `PARKED` entries; | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:293` | * retired history never appears and post-acknowledgement work never parks. At 021 this list | P3 | P3: deleted the stale/speculative sentence `At 021 this list is always empty: parking is produced by Issue 023 over Issue 022's durable rows.` Verified false in this tree: `MutationEngine.publishDurablePark` populates `deadLettersByMutationId` and hydration rehydrates `PARKED` rows. Kept the two verified clauses (`PARKED`-only; retired history never appears; post-acknowledgement work never parks — post-ack failures go through `retryablePostAckPersistence`, which retries rather than parks). Pinned by `org.mobilenativefoundation.store6.mutations.MutationDrainParkingTest`. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:294` | * is always empty: parking is produced by Issue 023 over Issue 022's durable rows. | P3 | Same hunk as MutationStore.kt:293. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:308` | * Read-only, in-process advisory lifecycle events (D4): replay `0`, extra buffer capacity | P1 | P1: deleted `(D4)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:310` | * or settlement protocol; durable truth remains inspection. Issue 023 owns causal emission. | P3 | P3: deleted `Issue 023 owns causal emission.`; kept the verified advisory-bus contract (replay `0`, extra capacity `64`, `DROP_OLDEST`, non-blocking) and the never-a-protocol clause. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:316` | /** The exact Bookkeeper the engine retained (D9); test/022/024 verification door. */ | P4 | P4 + P1: `(D9); test/022/024 verification door` -> `; the verification door for retention tests`. Verified against `MutationStoreBuilderTest.explicitBookkeeper_isSameInstanceForStoreAndMutationEngine` / `defaultBookkeeper_isSameInstanceForStoreAndMutationEngine`, which read this accessor. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:320` | /** The exact SourceOfTruth the engine retained (D9); test/022/024 verification door. */ | P4 | P4 + P1: same rewrite as MutationStore.kt:316. Verified against `MutationStoreBuilderTest.explicitSourceOfTruth_isSameInstanceForStoreAndMutationEngine` / `defaultSourceOfTruth_isSameInstanceForStoreAndMutationEngine`. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:335` | // (D14): the waiter observes the closed signal, cancels promptly, and its `first` | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:362` | * The ruled entry point (D1): restart behavior is compile-time required — the registry, server, | P1 | P1: deleted `(D1)`, `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:366` | * delegated Store AND retained by the engine, so Issue 024's transactional decorator can select | P3 | P3: deleted `so Issue 024's transactional decorator can select and report its path`; kept the verified contract that the retained selections are installed in the delegated Store AND retained by the engine, restated as `so the selection is never an inaccessible core default`. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:367` | * and report its path instead of silently discovering an inaccessible core default (D9). The | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:26` | * layer and is installed by the factory (D9, R1-13). The required mutation inputs — registry, | P1 | P1: deleted `(D9, R1-13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:28` | * builder doors (D1). | P1 | P1: deleted `(D1)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:34` | * silently substituted (D9). Issue 024 selects its transactional decorator — or reports its | P3 | P3 (worked example): deleted `Issue 024 selects its transactional decorator — or reports its explicit non-transactional fallback — against the retained persistence instance.`; kept the never-silently-substituted contract. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:94` | * sides (D9). Custom implementations should be validated with the source-of-truth contract | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:95` | * kit. Issue 024 selects transactional adoption — or reports its explicit non-transactional | P3 | P3: deleted the same Issue 024 sentence from the `persistence` door; kept the retained-instance contract and the contract-kit pointer. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:151` | * The surface and its registration validation land at Issue 021 (D2); precondition selection | P3 | P3: deleted `The surface and its registration validation land at Issue 021 (D2); precondition selection and merge execution are owned by Issue 023's fixed conflict pipeline (R1-19), so nothing registered here executes at 021.` Verified stale: `MutationEngine.selectPreconditionMeta` invokes `conflicts?.precondition` and `resolveDurableConflict` invokes `conflicts?.merge`. Kept the server-wins terminal and last-registration-wins contracts. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:152` | * and merge execution are owned by Issue 023's fixed conflict pipeline (R1-19), so nothing | P3 | Same hunk as MutationStoreBuilder.kt:151. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:153` | * registered here executes at 021. Without a registered merge, server-wins is the | P3 | Same hunk as MutationStoreBuilder.kt:151. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:168` | * mutations-owned [InMemoryMutationJournalStorage], preserving the 021 in-memory behavior. | P3 | P3: `preserving the 021 in-memory behavior` -> `so the journal stays in memory`; the restart-hydration sentence is unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:206` | * Registers the conflict policy surface ruled by D2. | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:208` | * Registration-time validation only at Issue 021: each policy registers at most once per block, | P3 | P3: `Registration-time validation only at Issue 021` -> `Registration is validated as it happens`; deleted `nothing registered here is executed before Issue 023's pipeline lands` (stale). Kept the at-most-once and no-terminal-setter contracts. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:209` | * and nothing registered here is executed before Issue 023's pipeline lands. There is | P3 | Same hunk as MutationStoreBuilder.kt:208. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:229` | * Store6 selects the candidate's captured metadata. Execution is owned by Issue 023 and runs | P3 | P3 + verified restatement: `Execution is owned by Issue 023 and runs once per newly prepared semantic generation, never on a transport retry (D2).` -> `The selector runs once per newly prepared semantic generation, never on a transport retry.` Verified: `selectPreconditionMeta` is called only from the durable prepare paths; a transport retry retransmits the same generation. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:230` | * once per newly prepared semantic generation, never on a transport retry (D2). | P1 | P1: deleted `(D2)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:248` | * repeat policy are owned by Issue 023 (D2, R1-19). | P4 | P4: `Execution, its fixed failure transitions, and its bounded repeat policy are owned by Issue 023 (D2, R1-19).` -> a restatement on the `merge` door: "Retrying is bounded: on the third consecutive conflict receipt carrying identical server metadata, the intent parks with a normalized `CONFLICT` failure instead of preparing another generation. A merge that throws parks the intent as well." Verified at two sites: `MutationEngine.kt:62` (`CONFLICT_UNCHANGED_BOUND: Int = 3`) and `MutationEngine.kt:2226`, where a trailing run of attempts whose conflict receipt has non-null meta and equal `conflictEtag`/`conflictWrittenAt` reaches the bound and commits `StoredExecutionPhase.PARKED` with a `MutationFailureKind.CONFLICT` failure (detail `conflict-unchanged-bound`); the merge-throws arm parks with detail `merge-failed`. Pinned by `org.mobilenativefoundation.store6.mutations.MutationConflictTest.unchangedRepeatedConflictEventuallyParks`. (Originally classed P3 and deleted under the P4 fallback; reclassified at review once the bound was located.) | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:275` | * The validated conflict policy retained for Issue 023's pipeline. | P3 | P3: `The validated conflict policy retained for Issue 023's pipeline.` -> `The validated conflict policy retained for the engine's conflict pipeline.` | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:277` | * Stored, never executed, at Issue 021. | P3 | P3: deleted `Stored, never executed, at Issue 021.` — verified stale (the policy is executed by the engine). | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilder.kt:294` | * the delegated core Store and the mutation engine (D9). [applyCoreConfiguration] replays every | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:10` | /** The fixed args-codec version used by every `delete` registration (D7). */ | P1 | P1: deleted `(D7)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:14` | * The one deliberate args-codec specialization (D7): Store6 owns the `delete` codec at fixed | P1 | P1: deleted `(D7)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:17` | * so Issue 022 can normalize the throw. | P4 | P4: `so Issue 022 can normalize the throw.` -> `so the engine normalizes the throw into a `CODEC` failure record.` Verified: `MutationEngine.classifyHydrationCodecFailure` builds a `MutationFailureKind.CODEC` normalized failure from a decode throw. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:38` | * The three type parameters keep key, value, and args compile-time bound at Kotlin call sites; / * R-2a records the experimental-tier waiver and requires a revisit at the first graduation / * review. | P2 | P2: deleted the graduation-review waiver `R-2a records the experimental-tier waiver and requires a revisit at the first graduation review` from published KDoc (rollout/approval state plus an internal referent). The compile-time-binding contract sentence is kept, with the trailing `;` closed to a `.`. The declaration's own `@ExperimentalStoreApi` annotation and STABILITY.md already carry the public stability contract. **Not matched by any sweep pattern** (`R-2a` fails `\bR[0-9]\b`); recorded here for completeness after review, so it is not counted in the 269 measured sweep hits. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:80` | /** Projects [base] through the registered mutator; `null` means decline only (D13). */ | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:119` | * `null` means exactly "decline this intent" (D13); a declined head remains pending and | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:124` | * [stales] is the pure declarative invalidation function (D8): equal inputs must produce | P1 | P1: deleted `(D8)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:184` | * confirmed base is ignored (D1). | P1 | P1: deleted `(D1)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutatorRegistry.kt:201` | * codec whose encoding is exactly zero bytes (D7). | P1 | P1: deleted `(D7)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/MutationJournalStorage.kt:104` | * Applies a ruled execution-state transition. | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:228` | // R1-05: the sealed Present variant adopts through apply -> confirmFresh, then retires; the | P1 | P1: deleted the `R1-05:` label only. **Tested-invariant sentence — the deliberate adopt-then-retire ordering on the non-transactional ack path.** Meaning preserved verbatim; pinned by `org.mobilenativefoundation.store6.mutations.MutationAckPathTest.presentAck_appliesConfirmsFreshThenRetires` (asserts `listOf("apply", "confirmFresh", "retire")`). | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:289` | // R1-05: the sealed Absent variant adopts through the bound clear door, then retires; the | P1 | P1: deleted the `R1-05:` label only. **Tested-invariant sentence — adopt-then-retire on the Absent arm.** Pinned by `org.mobilenativefoundation.store6.mutations.MutationAckPathTest.absentAck_clearsThenRetires_andHasNoCanonicalKey`. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:457` | // R1-18: present capture reads bookkeeping status BEFORE the LocalOnly value, and the | P1 | P1: deleted `R1-18`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:503` | // R1-18: absence is accepted only from the exact loop status -> LocalOnly Missing -> status | P1 | P1: deleted `R1-18`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:574` | // R1-18: a FetcherResult.Deleted window — the destructive clear that forgets freshness — | P1 | P1: deleted `R1-18`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:585` | // R1-18: a Store.clear(key) window shares the loop, not only the facade interlock. | P1 | P1: deleted `R1-18`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:891` | // DeleteAndCreatePending): under the ruled D13, delete is drainable — a projected Absent | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:1328` | // 017 residual-deadline repair: Turbine's 3s default nested inside the 25s shadow; raise the | P1 | P1: deleted `017 `; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAckPathTest.kt:1329` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | P1: deleted `(D0, PR #15)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:44` | * R1-20/R1-24/R1-09's 021 slice: the same-process canonical alias facade (D15a) and the D14 | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:49` | * The durable model is deliberately absent (022–024). Deferred proofs recorded, not faked: | P4 | P4: `The durable model is deliberately absent (022–024). Deferred proofs recorded, not faked:` -> `The durable model is deliberately out of scope here and is proven elsewhere:` with the issue prefixes stripped from each bullet; every referenced test file exists in this tree. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:50` | * - 022 `MutationJournalContractTest.kt::aliasEdgesAndActivation_roundTripAcrossRestart` | P4 | P4: issue prefix `022` stripped; the bullet now names `MutationJournalContractTest.kt::aliasEdgesAndActivation_roundTripAcrossRestart` alone. Verified: that file and test exist. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:51` | * - 023 `MutationAckOrchestrationTest.kt::ackAliasActivationRebasesQueuedSourceAndTargetSiblings` | P4 | P4: issue prefix `023` stripped; `MutationAckOrchestrationTest.kt::ackAliasActivationRebasesQueuedSourceAndTargetSiblings` exists in this tree. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:52` | * - 023 `MutationConflictTest.kt::serverWinsCancellationAfterCommit_stillPublishesOverlayRevision` | P4 | P4: issue prefix `023` stripped; `MutationConflictTest.kt::serverWinsCancellationAfterCommit_stillPublishesOverlayRevision` exists in this tree. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:53` | * - 023 `MutationDrainParkingTest.kt::parkingCancellationAfterCommit_stillPublishesOverlayRevisionAndRebasesSuffix` | P4 | P4: issue prefix `023` stripped; `MutationDrainParkingTest.kt::parkingCancellationAfterCommit_stillPublishesOverlayRevisionAndRebasesSuffix` exists in this tree. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:54` | * - 023 parks the alias-protocol violations that halt with a normalized `PROTOCOL` carrier here. | P4 | P4: `023 parks the alias-protocol violations …` -> `MutationDrainParkingTest also covers the durable parks for the alias-protocol violations that halt with a normalized PROTOCOL carrier here.` | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:69` | // queued siblings from source and target merge by durable client sequence (D15a). | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:115` | // definition, not stale (ruling: pending UI keys on origin == OVERLAY). | P1 | P1: deleted `ruling`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:123` | // to delegate.stream(canonical) (D15a): the confirmed canonical frame arrives | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:224` | // durable ACKED-never-repushed rule is 022/023's; this preview replays the generation.) | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:793` | // source key, no completion (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:857` | // the thrown cause in the immediate public exception only (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:866` | // records the normalized IDENTITY carrier and returns normally (023 converts these | P4 | P4: `(023 converts these halts into durable parks)` -> `The durable engine converts such a halt into a park.` Verified: `MutationEngine.parkDurableAckProtocolFailure` / `parkDurablePreAck`. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:875` | // Resolver null has no cause (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:911` | // mutate resolves BEFORE the append: failure creates no intent anywhere (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:951` | // reconstructed, so a dead resolver cannot fail this inspection (D14). | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:1335` | * R1-09's alias-facing ack-variant rule, standalone: a retry of one generation idempotency | P1 | P1: deleted `R1-09`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:1337` | * (D15a). The staging mirrors the retry-mismatch arm of | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:1513` | * canonical id (D15a); every other key acknowledges with an unchanged identity. | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:1526` | /** The ruled public entry point, used where no engine door is needed. */ | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:1810` | // 017 residual-deadline repair: Turbine's 3s default nested inside the 25s shadow; raise the | P1 | P1: deleted `017 `; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationAliasFacadeTest.kt:1811` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | P1: deleted `(D0, PR #15)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationBookkeeperContractTest.kt:9` | * R1-11: certifies the mutations-owned default [MutationBookkeeper] against the read-only | P1 | P1: deleted `R1-11`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationBookkeeperContractTest.kt:13` | * Every inherited kit member is a binding R1-11 test contract and runs on every compiled target: | P1 | P1: deleted `R1-11`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTest.kt:32` | * R1-17's 021 slice: `drain(key)` and `drain()` are idempotent, scheduler-agnostic foreground | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTest.kt:33` | * passes (D12) and the resolver — not any live key map — is global drain's correctness path | P1 | P1: deleted `(D12)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTest.kt:34` | * (D14). Restart enumeration is 022's `MutationJournalContractTest`; parked-identity | P4 | P4: the pointer "Restart enumeration is 022's MutationJournalContractTest" became "Restart enumeration is covered by `MutationJournalContractTest`" — the issue prefix is gone and the test name is the whole pointer. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTest.kt:35` | * continuation, retryable post-ack continuation, and the one-attempt-per-phase rule are 023's. | P4 | P4: `… the one-attempt-per-phase rule are 023's.` -> named the real suites `MutationDrainParkingTest` and `MutationDrainResumabilityMatrixTest`; both exist in this tree. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationDrainTest.kt:257` | // `update` over a stably absent base declines (D13): the head stays PENDING and blocks | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEffectsTest.kt:140` | // no effect executed (021 has no execution machinery — 022/023 own it). | P4 | P4: `021 has no execution machinery — 022/023 own it` -> `a codec-less engine has no durable effect rows to execute`. Verified: effect execution reads `durableEffectRows` in `resumeDurableEffectsPending`, which a codec-less engine never populates. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventSurfaceTest.kt:23` | // R1-22: the non-generic sealed algebra exposes the exact ruled stable fields for both the | P1 | P1: deleted `R1-22`, `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventSurfaceTest.kt:165` | // Sealed exhaustiveness over the event root needs exactly the three ruled branches. | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventSurfaceTest.kt:178` | // R1-22: checkpoint events are client-scoped and never fabricate a mutation identity. | P1 | P1: deleted `R1-22`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationEventSurfaceTest.kt:209` | // R1-22: the facade property is a read-only advisory SharedFlow backed by a non-blocking | P1 | P1: deleted `R1-22`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:25` | * R1-14/R1-15's 021 slices: truthful pending/pendingWrites/deadLetters snapshots (D3) and the | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:26` | * normalized failure carrier's sanitization contract. 021 exercises inspection shapes only and | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:27` | * never fakes the durable scheduler: `REFRESHING` and `APPLYING_EFFECTS` have no 021 producer | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:28` | * (023's conflict pipeline and 022/023's effect execution own them) and are proven through the | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:30` | * Restart hydration is 022's; parking, declined/parked scheduling, and dead-letter production | P4 | P4: "Restart hydration is 022's" became "Restart hydration is covered by `MutationJournalContractTest`". | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:31` | * are 023's `MutationDrainParkingTest`. | P4 | P4: "are 023's MutationDrainParkingTest" became "by `MutationDrainParkingTest`" — issue prefix removed, test name kept. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:36` | // The ruled total mapping (D3, R-0 §3): every nonterminal active phase maps to exactly | P1 | P1: deleted `(D3, R-0 §3)`, `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:52` | // Live: the phases the 021 foreground pass truthfully produces are visible through | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:141` | // All identities, durable client-sequence order, real enqueue stamps (D3). | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:199` | val key = MutationsTestKey("never-parked-at-021") | FP | FP / out-of-charter: the hit is inside the string literal `MutationsTestKey("never-parked-at-021")` — executable content, not a comment. Left unedited per the protected-content rule. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:205` | // Dead letters contain only durably PARKED executions (D3). 021 records the normalized | P4 | P4: `021 records the normalized in-memory carrier but never parks — parking is 023's transition over 022's rows` -> `A codec-less engine records the normalized in-memory carrier but never parks`. Verified: this test constructs `MutationEngine` without a `valueCodec`, so `durableJournal` is null and the legacy in-memory path runs; the assertion `assertEquals(emptyList(), engine.deadLetters())` is unchanged and still passes. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:206` | // in-memory carrier but never parks — parking is 023's transition over 022's rows — so | P4 | Same hunk as MutationInspectionTest.kt:205. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:266` | // The message budget is 1,024 bytes with the same code-point rule. | FP | FP: same `1,024` byte-budget number in a comment. No change. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspectionTest.kt:353` | // Poison is the ephemeral exact-Throwable flow (D3); it is not a drain failure carrier, | P1 | P1: deleted `(D3)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationJournalContractTest.kt:905` | // Adoption already committed its ruled ACKED -> EFFECTS_PENDING boundary; the injected | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationKeyResolverTest.kt:23` | * R1-02's 021 slice: the required resolver is global drain's correctness path (D14). Every test | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationKeyResolverTest.kt:26` | * unresolved pre-ack identity is 023's `MutationDrainParkingTest`; restart hydration is 022's. | P4 | P4: `is 023's `MutationDrainParkingTest`; restart hydration is 022's.` -> `is covered by `MutationDrainParkingTest`, restart hydration by `MutationJournalContractTest`.` | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:30` | // R1-01 (021 slice): the server signature receives the complete library-built carrier for a | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:81` | // R1-03. | P1 | P1: deleted `R1-03`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:103` | // R1-03. | P1 | P1: deleted `R1-03`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:119` | // R1-03. | P1 | P1: deleted `R1-03`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:142` | // R1-05: presence, never nullable V, crosses push/candidate/ack carriers. | P1 | P1: deleted `R1-05`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:174` | // R1-08. | P1 | P1: deleted `R1-08`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:201` | // R1-08: identity is the sole server-authoritative address; the resolved key is | P1 | P1: deleted `R1-08`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:236` | // R1-08 (021 slice): the exact-pair validation the engine runs before transport; D14 fixes | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:255` | // R1-08, strengthened to the engine path at T4.5 (Surface NOTES §3.11): the ENGINE rebuilds | P1 | P1: deleted `R1-08`, `T4`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:259` | // R1-08's 023 proof. | P4 | P4: `Durable INFLIGHT exact-replay is R1-08's 023 proof.` -> `… is proven by `MutationDrainResumabilityMatrixTest`.` Verified: that suite exists and covers INFLIGHT resumability. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:327` | // R1-08 adjunct (T4.5): the acknowledged authoritative value is rebuilt through the codec's | P1 | P1: deleted `R1-08`, `T4`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:399` | // R1-09: the sealed variants make a canonical target on confirmed absence unrepresentable. | P1 | P1: deleted `R1-09`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:428` | // R1-10: library-side monotonic validation keeps the consumer-built carrier plain. | P1 | P1: deleted `R1-10`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:470` | // R1-19 (021 slice): the capture candidate carries immutable captured metadata and nothing | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:510` | // R1-18 adjunct (carrier-level; the ordered-capture behavioral tests are T4.5's). | P1 | P1: deleted `R1-18`, `T4`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationProtocolTest.kt:525` | // T4.1 bullet: stable public enums expose the exact ruled value sets. | P1 | P1: deleted `T4`, `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationRestartWalkingTest.kt:100` | fetcher { error("The AC-4 LocalOnly scenario must not fetch") } | FP | FP / out-of-charter, and the ONE remaining Sweep-v2 hard-gate hit in this module: `fetcher { error("The AC-4 LocalOnly scenario must not fetch") }`. The referent lives inside a string literal in executable code, so editing it would change an executable token; per the verification rule the edit was stopped and the boundary is reported instead (same class as Amendment A1's recorded `error(...)`-message residue). | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruthContractTest.kt:11` | * R1-12: certifies the mutations-owned default [MutationSourceOfTruth] against the read-only | P1 | P1: deleted `R1-12`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationSourceOfTruthContractTest.kt:15` | * Every inherited kit member is a binding R1-12 test contract and runs on every compiled target: | P1 | P1: deleted `R1-12`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:36` | * R1-01/R1-11/R1-12/R1-13/R1-24 builder and factory forwarding contract (T4.3). | P1 | P1: deleted `R1-01`, `R1-11`, `R1-12`, `R1-13`, `R1-24`, `T4`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:38` | * The ruled `MutationStoreBuilder` mirrors core's optional doors, exposes no overlay door, and | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:40` | * Store and the mutation engine (D9). ABI absence of an overlay setter, runtime, and write-handle | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:43` | * `:store6-mutations:apiCheck` (R1-13); this common suite proves the doors' forwarding behavior. | P1 | P1: deleted `R1-13`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:49` | // factory parameters, never builder doors. The named-argument call pins the ruled | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:88` | // Engine side: the mutation engine retained the caller's exact instance (D9). | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:129` | // Engine side: the mutation engine retained the caller's exact instance (D9/024). | P3 | P3: deleted the future/ownership claim in this hunk; the surrounding code-verified contract was kept and made self-contained. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:203` | // ...and the same exact instances retained for the engine (D9). | P1 | P1: deleted `(D9)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:206` | // conflicts door (D2): surface and registration validation land at 021; nothing | P4 | P4: `surface and registration validation land at 021; nothing registered here executes before 023's pipeline` -> `nothing registered here runs for a merely pending mutation. The selector runs when a drain prepares a generation, the merge only on a conflict.` Verified against `selectPreconditionMeta` (prepare paths only) and the conflict path; the test's two `assertEquals(0, …)` assertions are unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:207` | // registered here executes before 023's pipeline, even with a pending mutation. | P4 | Same hunk as MutationStoreBuilderTest.kt:206. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:287` | // R1-24: the facade re-exposes the delegate runtime's advisory flow unchanged. The | P1 | P1: deleted `R1-24`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:311` | // The builder has no overlay door (compile-time absence; ABI proof is R1-13's dump | P4 | P4: `ABI proof is R1-13's dump test` -> `the ABI proof is the jvmTest dump test`, which the same file's class KDoc names in full. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationStoreBuilderTest.kt:348` | /** Minimal ruled two-method server: acknowledges this client's value and confirms retirement. */ | P1 | P1: deleted `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsTestFixtures.kt:22` | * (D15a): a canonical target in another namespace must be constructible so cross-namespace | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsTestFixtures.kt:33` | /** Exact-pair resolver for the module's single-namespace test key (D14). */ | P1 | P1: deleted `(D14)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsTestFixtures.kt:92` | * redirects a provisional identity (D15a); [absentPushBehavior] scripts Absent-projection pushes | P1 | P1: deleted `(D15a)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWalkingSkeletonTest.kt:245` | // 017 residual-deadline repair: Turbine's 3s default nested inside the 25s shadow; raise the | P1 | P1: deleted `017 `; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWalkingSkeletonTest.kt:246` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | P1: deleted `(D0, PR #15)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWiringSpikeTest.kt:21` | * Successor to `lastOverlayRegistrationWins` (T4.3's ruled compile-time posture). | P1 | P1: deleted `T4`, `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationsWiringSpikeTest.kt:26` | * displacement is superseded by compile-time absence. R1-13's dump audit proves the | P4 | P4: "R1-13's dump audit proves the ABI-level absence" became "`MutationApiSurfaceTest` proves the ABI-level absence". Verified: `MutationApiSurfaceTest.apiDumpContainsNoOverlaySetterRuntimeOrWriteHandleExposure` asserts the dump contains no `overlay(`, `StoreRuntime`, or `StoreWriteHandle`. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:54` | // R1-06. | P1 | P1: deleted `R1-06`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:77` | // R1-06. | P1 | P1: deleted `R1-06`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:93` | // Null is the decline signal (D13): the declined head never becomes an attempt and never | P1 | P1: deleted `(D13)`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:98` | // R1-06. | P1 | P1: deleted `R1-06`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:121` | // R1-06. | P1 | P1: deleted `R1-06`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:146` | // R1-06. | P1 | P1: deleted `R1-06`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:179` | // R1-04. | P1 | P1: deleted `R1-04`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:213` | // R1-04. | P1 | P1: deleted `R1-04`; the sentence is otherwise unchanged. | +| `store6-mutations/src/commonTest/kotlin/org/mobilenativefoundation/store6/mutations/MutatorSugarTest.kt:230` | // Any other durable pair is a codec violation for Issue 022 to normalize as CODEC. | P4 | P4: `a codec violation for Issue 022 to normalize as CODEC` -> `a codec violation the engine normalizes as CODEC`. Verified: `MutationEngine.classifyHydrationCodecFailure`. | +| `store6-mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationApiSurfaceTest.kt:9` | * R1-13: the committed KLib declaration exposes the ruled `MutationJournalStorage` seam, but no | P1 | P1: deleted `R1-13`, `ruled`; the sentence is otherwise unchanged. | +| `store6-mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationApiSurfaceTest.kt:29` | "Committed KLib dump is missing the ruled MutationJournalStorage seam.", | FP | FP / out-of-charter: `"Committed KLib dump is missing the ruled MutationJournalStorage seam."` is an assertion-message string literal (executable content). Left unedited; classify-only pattern, so it may remain. | +| `store6-mutations/src/jvmTest/kotlin/org/mobilenativefoundation/store6/mutations/MutationApiSurfaceTest.kt:70` | "Committed KLib dump differs from the da72d908 T0.3 baseline: $dumpSha256", | FP | Executable string literal (API-surface baseline pin), out of doc-pass charter; investigated at Task 4, ratified at Task 8. The referent (`da72d908` git-commit prefix, `T0.3` baseline label) sits inside an `assertTrue(...)` failure-message string in executable test code, git-blamed to commit `da72d9088187180124bc71aae94e0b0b336cf905` (pre-dating this branch's base `801b8e8`). Same class as the `:29` row above and the two `AC-4` `error(...)`-message FPs recorded for this module. Matched only by Task 8's extended referent-family check (`\bT[0-9]\.[0-9]\b`); no earlier sweep (v1, v2, or the ruling pattern) sees it. Left unedited. | + +Notes carried out of this pass: + +1. **`MutationSourceOfTruth.kt:41`** was resolved by Task 2 (`P3`); its row is retained in the Task 2 table and is not duplicated here (the excerpt no longer matches any sweep). +2. **Stale-not-just-speculative claims.** Several P3 sentences were not merely about future work — they were false in this tree, because the drain pipeline, journal storage, conflict pipeline, tombstones, and durable parking have all landed. Each was verified against code before deletion: `deadLetters()` "always empty" (`MutationEngine.publishDurablePark`), conflict policy "stored, never executed" (`selectPreconditionMeta`, `resolveDurableConflict`), effects "never executed" (`resumeDurableEffectsPending`), tombstones "not yet recorded" (`insertTombstone` / `publishDurableTombstoneReplacements`), and the alias router's "deferred proofs" list (all four named test files exist). +3. **`MutationStoreBuilder.kt:248` was corrected at review.** The first pass deleted "its bounded repeat policy" under the P4 fallback after failing to find a bound. The bound does exist: `CONFLICT_UNCHANGED_BOUND = 3` (`MutationEngine.kt:62`), applied at `MutationEngine.kt:2226`. The contract is now restated on the public `merge` door and the row is reclassified P4. No row in this module ended on the delete-the-rationale fallback. +4. **Referent families outside both sweeps, cleaned opportunistically inside already-flagged files** (same anti-pattern, adjacent to edited text): `R-0 §n` / `R-0 rule n`, `Q-1`/`Q-2`/`Q-4`/`Q-5`, `T3`/`T4.1`–`T4.5`, `Row 11`/`Row 18`/`rows 16-17`, `R-2a`, `(008 contract)`, `T2.4`, and multi-tag parentheticals like `(D12, D15b)` that the single-tag regex `\(D[0-9]+[a-z]?\)` does not match. No unflagged file was opened for style-only edits. +5. **Protected-content boundary (reported, not edited):** `MutationRestartWalkingTest.kt:100` keeps `error("The AC-4 LocalOnly scenario must not fetch")`. It is the module's only remaining Sweep-v2 hard-gate hit; the referent is inside a string literal, so removing it would change an executable token. Same class as Amendment A1's recorded `error(...)`-message residue. A one-token fix is available if the controller rules string literals in scope. + +--- + +## Task 5 — Adapter modules (store6-room, store6-sqldelight, store6-compose, store6-mutations-sqldelight) + +Scope: all baseline hits in each of the four adapter modules (full per-module lists; Task 2 above resolves the `signs off` stamp subset in `store6-room` and `store6-sqldelight` first), widened per Amendment A1 to the Sweep-v1 + Sweep-v2 union over each module's `src` tree. + +Sweep-v2 enumeration (run fresh for this task, scoped to the four module trees) surfaced **10 additional rows** not in the Task 1 baseline: 4 in `store6-room` (three "017 residual-deadline repair" companion lines to already-baselined Turbine-deadline lines, plus one new `AC-2-lite` test-doc tag), 0 in `store6-sqldelight`, 5 in `store6-compose` (`(OQ-3)`, two `Issue-017`/`issue-017` convention comments, and the `issue-007 bounded registry` phrase), 1 in `store6-mutations-sqldelight` (`AC-4` inside an `error(...)` string literal — protected, FP). These are added to the per-module tables below alongside the 23 baseline rows this task resolves (Task 2 already closed the 8 `signs off`/`Matt`/`issue 007` P2 stamp rows also listed here for module completeness). + +Post-pass state (both sweeps, all four module trees): v1 hard **0**, v1 classify-only **0**, v2 hard **1** (the `AC-4` string literal noted above), v2 classify-only **0**. + +Class counts across the 33 rows this task classified: **P1 29 · P3 1 · P4 2 · FP 1**. (The 8 P2 rows below were classified and resolved by Task 2, not this task.) + +#### `store6-room` (27 rows: 23 baseline + 4 Sweep-v2 additions) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeper.kt:22` | * Durable Room [Bookkeeper] backed by the adapter-owned TD-6 sidecar. | P1 | Deleted `TD-6 `; sentence otherwise unchanged. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeper.kt:41` | * This seam remains FREEZE CANDIDATE pending Matt signature. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruth.kt:206` | * When [withTransaction] wraps nested writes for the future TD-11 mutations decorator, nested | P3 | Deleted `for the future TD-11 mutations decorator`; the remaining sentence describes `withTransaction`'s current nested-write behavior (enlist in the outer transaction, invisible until commit), verified against `RoomTransactionalSourceOfTruthTest.nestedWrite_outerRollback_neverPublishesEcho` and `nestedWrite_outerCommit_publishesEchoOnlyAfterCommit`, which pin exactly that contract generically (no mutations-decorator dependency). | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruth.kt:212` | * Freeze candidate: issue 007 has landed; the seam freezes only after Matt signs the prepared | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruth.kt:213` | * sign-off package. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeeperDao.kt:8` | /** Room primitives for the adapter-owned TD-6 bookkeeping sidecar. */ | P1 | Deleted `TD-6 `; sentence otherwise unchanged. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeepingEntity.kt:14` | * This surface is a seam freeze candidate pending Matt's signature. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6BookkeepingEntity.kt:8` | * Adapter-owned TD-6 bookkeeping sidecar. | P1 | Deleted `TD-6 `; sentence otherwise unchanged. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6RoomSchema.kt:8` | * Migration SQL for the adapter-owned TD-6 sidecar. | P1 | Deleted `TD-6 `; sentence otherwise unchanged. | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/Store6WatermarkEntity.kt:9` | * Adapter-owned TD-6 watermark row. | P1 | Deleted `TD-6 `; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruthReaderSemanticsTest.kt:648` (Sweep v2) | // 017 residual-deadline repair: Turbine's 3s default nested inside the 25s shadow; raise the | P1 | Companion line to :649 below; both edited as one two-line comment. Deleted the `017 residual-deadline repair: ` prefix. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomSourceOfTruthReaderSemanticsTest.kt:649` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted ` (D0, PR #15)`. Combined with :648: two-line comment now reads "Turbine's 3s default nested inside the 25s shadow; raise the Turbine deadline above the shadow so runTest provides the only effective timeout." — matches the wording already landed for the same rationale in `store6-core` conformance tests (e.g. `SourceOfTruthConformanceTest.kt`). | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:111` | /** FS-6: disk hydration refetches unconditionally, then same-engine ETag reuse is conditional. */ | P1 | Deleted the leading `FS-6: ` test-doc tag; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:189` | /** FS-4 / TD-2: a namespace watermark survives Store replacement and forces a refetch. */ | P1 | Deleted the leading `FS-4 / TD-2: ` test-doc tags; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:244` | /** FS-4: clear removes both the user row and its durable freshness record. */ | P1 | Deleted the leading `FS-4: ` test-doc tag; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:274` | /** FS-4: clearNamespace runs the user delete and sweeps matching durable metadata only. */ | P1 | Deleted the leading `FS-4: ` test-doc tag; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:307` | /** FS-1 / FS-5: StaleIfError serves residence, reports failure, and remains causal-live. */ | P1 | Deleted the leading `FS-1 / FS-5: ` test-doc tags; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:378` | /** FR-10 / FS-3: a remote deletion cannot satisfy MustBeFresh on a missing row. */ | P1 | Deleted the leading `FR-10 / FS-3: ` tags. `FR-10` is not itself sweep-matched but is the same internal-referent family as the co-located `FS-3` tag it was joined to; both removed together for a self-contained sentence. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:400` | /** FR-10 / FS-3: LocalOnly hydrates a user-seeded Room row without fetching. */ | P1 | Deleted the leading `FR-10 / FS-3: ` tags, same rationale as :378 above. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:417` (Sweep v2) | /** AC-2-lite: concurrent public gets share one cold-key fetch. */ | P1 | Deleted the leading `AC-2-lite: ` test-doc tag; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:466` | /** FS-3: MaxAge uses durable write time after Store replacement and withholds stale data. */ | P1 | Deleted the leading `FS-3: ` test-doc tag; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:56` | /** FS-1 / AC-1: a cold public Store stream persists its fetched value through Room. */ | P1 | Deleted the leading `FS-1 / AC-1: ` tags (also a Sweep-v2 `AC-1` hit); sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:619` (Sweep v2) | // 017 residual-deadline repair: Turbine's 3s default nested inside the 25s shadow; raise the | P1 | Companion line to :620 below; both edited as one two-line comment, same rewrite as ReaderSemanticsTest.kt:648-649. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:620` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted ` (D0, PR #15)`; combined with :619 above, same rewrite as ReaderSemanticsTest.kt:648-649. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomStoreSubstitutionConformanceTest.kt:87` | /** FS-6: a fresh durable row and sidecar let a new Store skip fetching. */ | P1 | Deleted the leading `FS-6: ` test-doc tag; sentence otherwise unchanged. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomTransactionalSourceOfTruthTest.kt:385` (Sweep v2) | // 017 residual-deadline repair: Turbine's 3s default nested inside the 25s shadow; raise the | P1 | Companion line to :386 below; both edited as one two-line comment, same rewrite as ReaderSemanticsTest.kt:648-649. | +| `store6-room/src/hostTest/kotlin/org/mobilenativefoundation/store6/room/RoomTransactionalSourceOfTruthTest.kt:386` | // Turbine deadline above the shadow so runTest provides the only effective timeout (D0, PR #15). | P1 | Deleted ` (D0, PR #15)`; combined with :385 above, same rewrite as ReaderSemanticsTest.kt:648-649. | + +#### `store6-sqldelight` (4 rows: 4 baseline + 0 Sweep-v2 additions) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightBookkeeper.kt:35` | * This seam remains FREEZE CANDIDATE awaiting Matt signature. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruth.kt:35` | * Every user-row mutation and its matching TD-6 metadata mutation execute in one [Transacter] | P1 | Deleted `TD-6 `; sentence otherwise unchanged. | +| `store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruth.kt:62` | * Seam status: FREEZE CANDIDATE awaiting Matt signature; never frozen. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/internal/MetaSidecar.kt:14` | * Adapter-owned durable sidecar (TD-6). Four tables are created and versioned by the adapter in | P1 | Deleted `(TD-6)`; sentence otherwise unchanged. | + +#### `store6-compose` (9 rows: 4 baseline + 5 Sweep-v2 additions) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt:26` | * Closed-store behavior (finalized by issue 007): calling this on a closed store fails the | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycle.kt:24` (Sweep v2) | * catches up without a Loading reset). Under the landed issue-007 bounded registry a paused | P4 | Rewrote `the landed issue-007 bounded registry` -> `the bounded key registry`, naming the mechanism directly instead of via the internal issue tag. Verified: `KeyRegistry` (`store6-core/internal/KeyRegistry.kt`) is bounded by `StoreBuilder.maxIdleKeys`, the same `maxIdleKeys` this KDoc's next sentence already names. | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/StoreResultEquivalence.kt:33` | * issue-007 OQ-1 ruling — same-kind latest-wins, never merged across kinds — whose public | P4 | Deleted `as landed by the issue-007 OQ-1 ruling`; kept `This mirrors the engine's conflateLatestData discipline — same-kind latest-wins, never merged across kinds — whose public contract reads: …`. The mirrored claim is `conflateLatestData`'s own documented behavior, verified against its KDoc in `store6-core/internal/StoreResultFlows.kt` and the quoted `Revalidated` contract text in `store6-core/StoreResult.kt`. | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/StoreResultEquivalence.kt:39` | * convenience for stateIn/ViewModel consumers; the engine's TD-8 operator rule | P1 | Deleted `TD-8 `; sentence otherwise unchanged (the parenthetical `(conflateLatestData as its single custom operator)` already carries the rationale, verified: `conflateLatestData` is the sole function in `store6-core/internal/StoreResultFlows.kt`, the module's only custom-Flow-operator file). | +| `store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt:20` | * exact `Store.stream` seam they call. Close semantics were finalized by issue 007; the close | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt:21` (Sweep v2) | * ABI (OQ-3), so no message text is asserted here. | P1 | Deleted `(OQ-3)`; sentence otherwise unchanged. Verified: the close message is `storeClosedException()`'s `STORE_CLOSED_MESSAGE` (`store6-core/internal/StoreLifecycle.kt`), an internal diagnostic string not part of the public ABI. | +| `store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt:66` (Sweep v2) | // Issue-017 convention: one file-private 25s runTest shadow, no nested wall-clock waits. | P1 | Deleted the leading `Issue-017 convention: ` tag; sentence otherwise unchanged (capitalized `One` to open the sentence). | +| `store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ComposeTestHarness.kt:30` (Sweep v2) | * hop, no nested `withTimeout` (issue-017 test-runtime discipline). Every frame is pumped | P1 | Deleted `(issue-017 test-runtime discipline)`; sentence otherwise unchanged. | +| `store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ComposeTestHarness.kt:67` (Sweep v2) | // Issue-017 convention: one file-private 25s runTest shadow, no nested wall-clock waits. | P1 | Deleted the leading `Issue-017 convention: ` tag; sentence otherwise unchanged (capitalized `One` to open the sentence). | + +#### `store6-mutations-sqldelight` (1 row: 0 baseline + 1 Sweep-v2 addition) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-mutations-sqldelight/src/commonSqlTest/kotlin/org/mobilenativefoundation/store6/mutations/sqldelight/SqlDelightMutationRestartWalkingTest.kt:131` (Sweep v2) | fetcher { error("The SQLDelight AC-4 LocalOnly scenario must not fetch") } | FP | `AC-4` sits inside an `error(...)` string literal in executable test code (a fetcher stub's failure message) — protected content, out of doc-pass charter. No change. | + +--- + +## Task 6 — Testing/devtools modules, test sources, unpublished modules + +Scope: all baseline hits in `store6-testing`, `store6-mutations-testing`, `store6-devtools`, `store6-devtools-inspector`, and the unpublished modules (`store6-benchmarks`, `store6-quickstart`, `store6-extension-probe`, `store6-compose-demo`, `store6-devtools-demo`) — full per-module lists (Task 2 above resolves the `signs off` stamp subset in `store6-testing` first). Per Amendment A1, this task also runs a fresh Sweep-v2 enumeration (hard: `engine-design|design §|§[0-9]+|\bTEST-[0-9]+\b|\bC-[0-9]{2}\b|\bAC-[0-9]+\b|\bOQ-[0-9]+\b|\brow-7/8\b`; classify-only: `\b0(0[1-9]|1[0-9]|2[0-9])\b|\bR[0-9]\b|\bT2E\b`) plus the `ruling|ruled|adopted shape|erratum` classify-only pattern over all nine module trees, deduplicated against the Task 1 baseline. Result: **9 new rows** — 4 in `store6-testing` (v2 classify-only: `TestStoreResults.kt:15`, `FakeBookkeeperAlgebraTest.kt:21`, `FakeFetcherIntegrationTest.kt:31`, `FakeStoreConformanceTest.kt:277`), 1 in `store6-devtools` (v2 classify-only `Issue-017` in `StoreDevtoolsMonitorIntegrationTest.kt:105`), 1 in `store6-benchmarks` (v2 hard `OQ-6` in `TelemetryAllocationProbe.kt:18`), 1 in `store6-compose-demo` (v2 hard `OQ-6` in `Main.kt:15`; the `StabilityProbe.kt:16` row was already an unclassified Task-1 baseline row, not new), 2 in `store6-devtools-demo` (v2 classify-only: `DemoApp.kt:54`, `DemoFetcherTest.kt:54` — this module had zero Task-1 baseline hits). `store6-devtools-inspector`, `store6-quickstart`, and `store6-extension-probe` return zero hits under both sweeps and the ruling pattern; no rows. + +Also resolved: five named residue items pre-authorized by the controller from earlier task reviews (not sweep hits at enumeration time in four of five cases — see the dedicated subsection after the module tables). + +Post-pass state (all nine module trees plus the five named items): v1 hard **0** (one recorded FP survivor, test-fixture data — see `store6-testing` table), v2 hard **0**, v2 classify-only **0**, ruling classify-only **0**. Full-tree (`store6-*/src`) re-run after this task's edits: v1 hard **0** except the same recorded FP; v2 hard **0** except the two `AC-4` `error(...)`-string-literal FPs already recorded by Tasks 4 and 5 (`MutationRestartWalkingTest.kt:100`, `SqlDelightMutationRestartWalkingTest.kt:131`); v2/ruling classify-only survivors are all previously recorded FPs (Task 3b: `SourceOfTruthHydrationRaceTest.kt:97`, `KeyEnginePlanningTest.kt:3440`; Task 4: `MutationInspection.kt:48`/`:62`, `MutationInspectionTest.kt:198`/`:265`, `MutationApiSurfaceTest.kt:29`). + +Class counts for rows classified by this task: **P1 16 · P4 7** (23 rows: 20 module-scope + 3 named-residue `store6-core` rows). Carried-forward rows already resolved by Task 2 (P1/P2/P3/FP) are marked `(Task 2)` and not re-counted here. + +#### `store6-testing` (16 baseline hits + 4 Sweep-v2 additions = 20 rows) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:274` | * Close semantics finalized by issue 007. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:40` | * Invalidation implements Decision #37 (Matt, 2026-07-20): it is a stale-mark only and never | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:437` | * Decision #37 (ruled by Matt, 2026-07-20): invalidate is a stale-mark only, the engine's | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:485` (Task-1 baseline recorded this as `:487`; Task 2's edit shifted it) | // Core keeps STORE_CLOSED_MESSAGE internal by design (FS-5 — | P1 | Deleted the `FS-5 — ` tag/connector inside the parenthetical (Task 2 already removed the preceding `Finalized by issue 007: `). Result: `(diagnostics are review-gated text, not ABI)`; the rationale clause is unchanged and self-contained. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:59` | * The seam consumed here is a FREEZE CANDIDATE, not frozen: freeze sign-off remains held until | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:60` | * issue 007 lands and Matt signs off. [close] is synchronous and idempotent. Active collectors are | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:63` | * text were finalized by issue 007 against the engine's close lifecycle. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/SourceOfTruthContractKit.kt:22` | * Conformance kit for [SourceOfTruth] implementations (TD-15). Extend it in your test source set, | P1 | Deleted ` (TD-15)`; the sentence is otherwise unchanged. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:191` | // THE Decision #37 alignment pin (ruled by Matt, 2026-07-20): with NO active demand, | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:32` | * There is no invalidate-divergence row: Decision #37 (Matt, 2026-07-20) aligned the fake to the | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:375` | // Finalized by issue 007: pins verified against StoreCloseLifecycleTest in store6-core. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:386` | // Finalized by issue 007: pins verified against StoreCloseLifecycleTest in store6-core. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:83` | assertTrue(ex.message!!.contains("test/1")) // FS-5: which key | P1 | Deleted the `FS-5: ` tag prefix, leaving `// which key`. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:84` | assertTrue(ex.message!!.contains("enqueueFetchValue")) // FS-5: the fix | P1 | Deleted the `FS-5: ` tag prefix, leaving `// the fix`. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/UserViewModelSampleTest.kt:45` | fake.enqueueFetchValue(key, User("42", "Matt")) | FP (Task 2) | No change — test-fixture data literal, not documentation. See Task 2 table for detail. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/UserViewModelSampleTest.kt:49` | assertEquals("Matt", assertIs(awaitItem()).name) | FP (Task 2) | No change — test-fixture data literal, not documentation. See Task 2 table for detail. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestStoreResults.kt:15` (Sweep v2) | * [StoreResults] door (008 — the sanctioned construction path; StoreResult, StoreError, and | P1 | Deleted `008 — `; kept `(the sanctioned construction path; StoreResult, StoreError, and StoreException constructors are internal)`. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperAlgebraTest.kt:21` (Sweep v2) | assertFalse(status.durablyStale) // 006 pin: no success + no covering mark -> false (?: 0 zero floor) | P1 | Deleted the `006 pin: ` tag prefix; the assertion note is otherwise unchanged. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeFetcherIntegrationTest.kt:31` (Sweep v2) | assertEquals("v1", store.get(key, Freshness.MustBeFresh)) // 006 pin: MustBeFresh + resident etag -> | P1 | Deleted the `006 pin: ` tag prefix; the two-line comment (concluded on line 32, unedited) is otherwise unchanged. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:277` (Sweep v2) | assertFalse(data.isStale) // 304 cleared staleness (006 pin) | P1 | Deleted the ` (006 pin)` suffix; `304` is domain terminology (the HTTP not-modified status, which is what the assertion is about), not an internal referent. | + +#### `store6-mutations-testing` (1 hit at baseline) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-mutations-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/testing/MutatorPurityContractKit.kt:126` | * Published TD-12 conformance kit for durable mutator projectors. | P1 | Deleted `TD-12 `, leaving `Published conformance kit for durable mutator projectors.` (`Published` describes public visibility, not provenance.) | + +#### `store6-devtools` (2 baseline hits + 1 Sweep-v2 addition = 3 rows) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-devtools/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/CompositeStoreTelemetry.kt:15` | * `telemetry(storeTelemetryOf(logger, monitor))`. This is FS-10's multiplex posture: | P4 | Rationale was expressed only via the `FS-10` referent. Restated from the component itself: deleted `This is FS-10's multiplex posture:` and capitalized the following clause, so the sentence reads `Extension vocabularies and apps may share one application sink without adding methods to the core interface.` Verified against the class: `CompositeStoreTelemetry` fans one installation out to a caller-supplied `List` (constructor param `sinks`), exactly the sharing behavior described. | +| `store6-devtools/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/StoreDevtoolsEvent.kt:13` | * decided: values never cross this seam. The seam remains a freeze candidate and sign-off is held. | P2 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-devtools/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/StoreDevtoolsMonitorIntegrationTest.kt:105` (Sweep v2) | // Issue-017 convention: one file-private 25s runTest shadow, no nested wall-clock waits. | P1 | Deleted the `Issue-017 convention: ` tag prefix, leaving `// One file-private 25s runTest shadow, no nested wall-clock waits.` Matches the identical rewrite Task 5 already applied to the same sentence in `store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ComposeTestHarness.kt:66` and `ClosedStoreBehaviorTest.kt:66`. | + +#### `store6-devtools-inspector` (0 hits — confirmed under v1, v2, and the ruling pattern) + +No hits in this module. + +#### `store6-benchmarks` (5 baseline hits + 1 Sweep-v2 addition = 6 rows) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/StreamEmissionBenchmark.kt:24` | * METRIC-1: stream-emission overhead versus the raw SoT flow (NFR-8, TD-8, TEST-7). | P1 | Deleted the whole parenthetical `(NFR-8, TD-8, TEST-7)`, leaving `* METRIC-1: stream-emission overhead versus the raw SoT flow.` `NFR-8` is not itself matched by the sweep regex but is the same internal-referent family as the co-located `TD-8`/`TEST-7` tags, so it was removed with them. `METRIC-1` is left unchanged — it is a label this file defines and uses consistently for itself (also at line 33), not an outward-pointing tracker referent. | +| `store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt:21` | * collections — issue 007's OQ-5 explicitly deferred grace tuning (and retry-backoff shape) to | P3 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt:24` | * The measured half of FS-10's "zero cost when unset" (008's deferral; StoreTelemetryTest.kt:114). | P4 | Rationale for the `StoreTelemetryTest.kt:114` cross-reference was expressed only via `FS-10`/`008's deferral`. Restated in terms of the component: `The measured half of the telemetry "zero cost when unset" claim; the allocation-count half is TelemetryAllocationProbe (see StoreTelemetryTest.kt:114).` Verified: `TelemetryAllocationProbe` is the class at `store6-benchmarks/src/test/.../TelemetryAllocationProbe.kt`, and it performs exactly that measurement. | +| `store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt:26` | * FS-10's evidence is measured plus structural, not a literal differential against a telemetry-free | P1 | Replaced `FS-10's` with `This`, self-referencing the claim named in the immediately preceding (line-24) sentence. Rest of the sentence unchanged. | +| `store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt:13-14` | * Allocation evidence for FS-10's measured-plus-structural zero-cost-when-unset claim — the / * "allocation-count measurement" StoreTelemetryTest.kt:114 defers to this module. Reports | P4 | **Also named item, Task 3b review (item 1):** the class KDoc claimed `StoreTelemetryTest.kt:114` "defers to this module," but Task 3b's edit of that line (see Task 3b table) changed its wording from "is deferred to" to "lives in" — the class should describe itself, not the stale deferral framing. Deleted `FS-10's` and rewrote self-containedly: `Allocation evidence for the measured-plus-structural zero-cost-when-unset claim: this module performs the allocation-count measurement StoreTelemetryTest.kt:114 references.` Verified: `TelemetryAllocationProbe.residentServe_callerThreadAllocationDelta_reported` measures caller-thread allocated bytes/op, matching the description. | +| `store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt:18` (Sweep v2) | * REPORT-ONLY by design: prints a table, asserts nothing numeric (thresholds are OQ-6/first-data | P1 | Deleted the `OQ-6/first-data` referent and restated the meaning it carried: `(thresholds are OQ-6/first-data territory)` -> `(no threshold is defined yet)`. Verified: the class only `println`s a table and never asserts a numeric bound, consistent with "no threshold defined." | + +#### `store6-quickstart` (0 hits — confirmed under v1, v2, and the ruling pattern) + +No hits in this module. + +#### `store6-extension-probe` (0 hits — confirmed under v1, v2, and the ruling pattern) + +No hits in this module. (The brief flagged this module as a likely `row-7/8`-family carrier since `CoordinatedTransactionalSourceOfTruth.kt` implements the decorator protocol `TransactionalSourceOfTruth.kt`'s deleted paragraph described — per Task 3b's concern note. On inspection its own KDoc documents that protocol in terms of its own components already, with no banned referent.) + +#### `store6-compose-demo` (2 baseline hits + 1 Sweep-v2 addition = 3 rows) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt:12` | // Process-scoped store on the landed bounded-registry engine (issue 007): idle key engines | P1 (Task 2) | Handled by Task 2 — see Task 2 table for detail. | +| `store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt:15` (Sweep v2) | // window exit tears the JVM down, and core's close carries a GC-fallback posture (OQ-6). | P1 | Deleted ` (OQ-6)`; the sentence is otherwise unchanged. | +| `store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/StabilityProbe.kt:16` | * parameters; the gate posture for these follows the T6 calibration ruling recorded in | P4 | The "Iface tier" gate-posture rationale was expressed only via `the T6 calibration ruling` plus a dangling path, `docs/v6/decisions/store6-compose-packaging.md` — confirmed absent from the tree (`git ls-files docs/v6` returns nothing; no `decisions/` directory exists anywhere). Unlike the sibling "Strict tier" sentence, no code-verifiable rationale for the tier's gate posture could be found (no CI workflow or stability-conf entry distinguishes an "Iface tier" threshold). Per the P4 rule, deleted the unverifiable rationale and kept the structural contract: `Iface tier: interface/abstract-typed parameters.` — verified against the file's own `ProbeIface*` functions, which do take interface/abstract-typed parameters (`StoreResult`, `Freshness`, `StoreKey`, `StoreMeta`, `StoreError`). | + +#### `store6-devtools-demo` (0 baseline hits + 2 Sweep-v2 additions = 2 rows) + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-devtools-demo/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoApp.kt:54` (Sweep v2) | /** Live knobs the demo screen mutates while the store keeps fetching (the 012 demo pattern). */ | P1 | Deleted the whole parenthetical `(the 012 demo pattern)`, leaving a self-contained sentence. | +| `store6-devtools-demo/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoFetcherTest.kt:54` (Sweep v2) | // Issue-017 convention: one file-private 25s runTest shadow, no nested wall-clock waits. | P1 | Deleted the `Issue-017 convention: ` tag prefix; same rewrite as `store6-devtools/.../StoreDevtoolsMonitorIntegrationTest.kt:105` above and the store6-compose precedent (Task 5). | + +#### Named residue items (pre-authorized, Task 3b review) — items 2-4 of 5 + +Item 1 (`TelemetryAllocationProbe.kt:14`) is resolved above in the `store6-benchmarks` table (same hunk as its `FS-10` sweep hit). Item 5 is an inventory-only relabel of the Task 3b table's `StoreTelemetryTest.kt:114` row (see the amendment at the end of the Task 3b section, above). Items 2-4 are not sweep hits — none of the removed phrases match Sweep v1, v2, or the ruling pattern — and are resolved here as controller-named residue, all in already-cleaned `store6-core` files: + +| file:line | excerpt | class (P1-P4/FP/Unverifiable) | action taken | +| --- | --- | --- | --- | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceConformanceTest.kt:14-15` | * This proves store-instance-independent durable facts, not on-disk or cross-process durability, / * which the persistence adapter modules cover. | P4 | Named item, Task 3b review (item 2). The coverage claim overclaimed: `SqlDelightDurableMaintenanceTest` and `RoomStoreSubstitutionConformanceTest` (the persistence adapter tests Task 3b's P4 note on this same file identified) run in-process against a real driver/on-disk store — they do not prove cross-process durability. Dropped `or cross-process` from the claim: `not on-disk durability, which the persistence adapter modules cover.` The exclusion this fixture's own claim makes (store-instance-independent facts only, verified via `InMemoryBookkeeper` and an in-memory SoT) is unchanged and accurate. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt:89` | // (d) pins get's posture: a resident value is served without a refetch | P4 | Named item, Task 3b review (item 3). Dropped the vague `pins get's posture: ` clause, keeping `// (d) a resident value is served without a refetch` — capitalization (lowercase after the `(d)` tag, no trailing period) matches the sibling labels at lines 26, 41, 55, 64, 109. Pinned by the test the comment labels, `getAfterStreamCommitted_servesResidentValueWithoutRefetch`. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt:23` | * - Bulk sweeps retain each snapshotted engine for the action's duration, preserving the landed / * double-sweep-under-fence semantics: ... | P4 | Named item, Task 3b review (item 4) — flagged as residue by Task 3b's concerns note (its item 3). Deleted the landing-state adjective `landed`, leaving `preserving the double-sweep-under-fence semantics: ...` The mechanism name `double-sweep-under-fence` stays: it describes the code (the watermark-plus-fence behavior the same sentence goes on to state) and is not an organizational-status reference. | + +--- + +## Task 7 — Public-surface KDoc quality audit (alpha01 artifacts) + +Not sweep-driven (this is a read-only audit of public-declaration KDoc completeness/quality against the interface-documentation checklist, over `store6-core`, `store6-mutations`, `store6-testing`, `store6-sqldelight`, `store6-room`, `store6-compose`), so no baseline rows apply here. Task 7 appends its own findings table using columns `file:line | class | evidence | remediation` (finding classes: Missing / Stale / Contradictory / Duplicated / Unverifiable / Misleading / Unnecessary / Sufficient) when it runs. + +### Coverage and method + +Audited: every `public` declaration in the `commonMain` source sets of the six alpha01 artifacts (explicitApi mode spells every intended-public symbol `public`). Declaration counts are `grep -c` of `public` occurrences per module `commonMain`, which is the population the audit walked file by file: + +| module | public declarations audited / total | files with public surface | actionable finding rows | +| --- | --- | --- | --- | +| `store6-mutations` | 333 / 333 | 9 of 13 (`MutationEngine`, `MutationJournal`, `MutationBookkeeper`, `MutationSourceOfTruth` are internal-only) | 10 | +| `store6-core` | 164 / 164 | 22 of 43 (the whole `core/internal` package is internal-only) | 13 | +| `store6-testing` | 79 / 79 | 10 of 10 | 10 | +| `store6-room` | 35 / 35 | 6 of 7 (`RoomStoreMeta` is internal-only) | 1 | +| `store6-sqldelight` | 11 / 11 | 2 of 7 (the whole `sqldelight/internal` package is internal-only) | 1 | +| `store6-compose` | 6 / 6 | 3 of 3 | 4 | +| other (`store6-mutations-testing`, cross-module FP) | — | — | 2 | +| **total** | **628 / 628** | **52** | **41** | + +Row counts include the cross-module `landed` section below, whose five rows are attributed here to the module each site lives in. **One row can cover many declarations** (`MutationFailureKind` is nine; the nine journal record classes are 92 properties), so row counts are not declaration counts — see the declaration accounting below. + +Zero-KDoc `commonMain` files, checked for intended-public status first: `store6-core/.../internal/MaintenanceCoordinator.kt` (private + internal only — correctly none), `store6-room/.../RoomStoreMeta.kt` (internal only — correctly none), `store6-testing/.../FakeStoreInteraction.kt` (**public, 10 declarations, no KDoc** — recorded below). The brief's fourth file was in `store6-devtools-inspector`, outside the alpha01 audit set. + +Fix classes applied: Missing / Stale / Contradictory / Misleading / Unnecessary. Rows marked **recorded, not fixed** are honest deferrals under the plan's warrant rule (no claim may be written that could not be verified against code in this pass). + +### Findings — `store6-mutations` + +| file:line | declaration | class | evidence | remediation | +| --- | --- | --- | --- | --- | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationInspection.kt:32` | `MutationFailureKind` (9 enum entries) | Missing | The nine constants carry no per-entry documentation and the enum KDoc states only the collective purpose, yet `MutationFailure.kind` is reachable from `DeadLetter.failure`, `MutationFailed.failure`, `MutationParked.failure`, and `MutationCheckpointFailed.failure` — a caller branching on the kind has to read the engine. Producer sites pin every constant: IDENTITY `MutationEngine.kt:1137,1482,3192` (resolver null / mismatch / throw, details at `MutationEngine.kt:64-72`), CODEC `:421,:580,:2452` (details `value-codec-pre-ack`, `value-codec-acked`, `args-codec`, `mutator-missing` at `:49-52`), PROJECTION `:1962` (`projection-throw`), PROTOCOL `:799,:2758,:3885` and `MutationProtocol.kt:369`, CONFLICT `:2206,:2326,:2392` (`conflict-unchanged-bound`, `merge-failed`, `selector-failed`), TRANSPORT `:782,:2131` (`retire-failed`, `push-failed`), ADOPTION `:2988,:3005` (`adoption-failed`), EFFECT `:3116` (`effect-target-failed`), PERSISTENCE `:827,:860` (`retire-confirmation-failed`, `retire-prune-failed`). | Fixed: one KDoc line per constant, each naming only the failure source proven by its producer sites. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:372` | `mutationStore(...)` | Missing | `MutationStoreBuilder.snapshot()` (`MutationStoreBuilder.kt:182-185`) throws `IllegalArgumentException` when `configure` installs no fetcher door, and the factory's own `require(valueCodecVersion >= 1)` (`MutationStore.kt:380`) throws for a non-positive codec version. Neither is documented, while the mirror core factory `store()` does document its equivalent (`StoreBuilder.kt:29`). | Fixed: added the two `@throws IllegalArgumentException` clauses, matching core's wording and tag style. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationStore.kt:219` | `MutationStore.mutate` | Missing | The function returns `String` and the KDoc never says what it is. `MutationEngine.mutate` returns `mutationId` (`MutationEngine.kt:686`), the same opaque id documented on `PendingIntent.mutationId` and every `MutationIntentEvent`. `MutationEngine.kt:616-618` also throws `IllegalArgumentException` for a `MutatorRef` from another registry. | Fixed: added `@return` for the mutation id and `@throws IllegalArgumentException` for the foreign-registry ref. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEvents.kt:294` | `MutationEventBus` (named extra 4) | Unnecessary | "Channels and actors are banned as protocols" asserts a prohibition with no stated rationale and no code referent; the sentence that follows ("Emission is [tryEmit]-only, so lifecycle work can never block or suspend on telemetry…") already carries the whole contract, pinned by `MutationEventBus.tryEmit` (`MutationEvents.kt:313`) and the `replay = 0 / extraBufferCapacity = 64 / DROP_OLDEST` construction at `:303-307`. | Fixed: dropped the prohibition clause, kept the mechanism sentence ("A `MutableSharedFlow` configured with [BufferOverflow] carries the events."). | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/MutationJournalRecords.kt:11` | `MutationExecutionPhase` (8 enum entries) | Missing | No per-entry documentation. Six of the eight already have a published meaning through `MutationPendingState`'s mapping KDoc (`MutationInspection.kt:17-19`); the two terminal ones are pinned by `MutationExecutionRecord`'s init block (`MutationJournalRecords.kt:130-135`: `activeFailureId` exists exactly in `PARKED`, `retiredAt` exactly in `RETIRED`). | Fixed: added one paragraph naming the two terminal phases and pointing the six nonterminal ones at the `MutationPendingState` mapping. No per-entry prose invented. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/storage/MutationJournalRecords.kt:54,78,108,146,218,258,282,307,330` | the nine durable record classes (92 public properties) | Missing | Every record class carries a one-line class KDoc and then declares its properties with no documentation at all (only `argsBlob`, `baseBlob`, `mineBlob`, `authoritativeBlob` — the copying accessors — were documented). A storage implementer cannot derive `idempotencyRoot`, `advertisedRetiredThroughSequence`, `activeFailureId`, `preconditionMetaPresent`/`preconditionWrittenAt`, `recordVersion`, or `rowId` from the names. | **Fixed in part (10 of 92 documented: 4 pre-existing blob-accessor properties — `argsBlob`, `baseBlob`, `mineBlob`, `authoritativeBlob` — plus 6 fixed in this pass), remainder recorded.** Review correctly noted that `MutationExecutionRecord`'s `init` block already pins six fields, so no verification work was outstanding for them: `clientSequence` (`:126` positive), `currentGeneration` (`:127` non-negative, `:143-147` zero only in `UNPREPARED` or a never-prepared `PARKED`), `attempt` (`:128` non-negative, `:132-136` zero while the generation is zero), `lastAttemptAt` (`:129-131` non-null exactly when `attempt` is nonzero), `activeFailureId` (`:137-139` non-null exactly in `PARKED`; the value is a `MutationFailureRecord.failureId`, pinned by `MutationEngine.kt:2243,:2518,:2781`), and `retiredAt` (`:140-142` non-null exactly in `RETIRED`). One KDoc line each was written from those pins; times are Unix epoch milliseconds per `MutationJournalStorage`'s own contract. The remaining 82 properties stay deferred, and the deferral is now narrower and honest: they are the fields whose contracts are **not** pinned by an `init` `require` — `idempotencyRoot`, `advertisedRetiredThroughSequence`, `recordVersion`, `rowId`, the alias/tombstone provenance fields, and the precondition/conflict receipt fields, each of which needs its engine or storage write site read before a claim can be made. Compile-enforced cross-field invariants and the per-operation rules on `MutationJournalStorage` remain the mitigation. Remediation for a follow-up: one line per remaining property, pinned to the call site that writes it. **[Task 8 correction, mechanical recount]** Two earlier figures for this row's documented-property count were wrong: this task's Task 7 narrative said "11 are now documented" (a summation error — never reconciled against the 4 pre-existing + 6 new = 10 actually named above) and a Task 8 controller-resolution draft guessed "6" (reading only the newly-fixed count as the total, missing the 4 pre-existing blob accessors). Task 8 mechanically re-derived the count directly from the file: for every `public val` (92 total), walk backward over blank/pure-annotation lines to the nearest substantive line and call the property documented iff that line ends in `*/`. Result: exactly 10 documented (`argsBlob`, `MutationExecutionRecord.clientSequence`, `currentGeneration`, `attempt`, `lastAttemptAt`, `activeFailureId`, `retiredAt`, `baseBlob`, `mineBlob`, `authoritativeBlob`), 82 undocumented. 92 − 10 = 82, confirming the "remaining 82" above. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:2459,3646,3658` | internal `parkDurableConflictFailure`, cache-replacement and bookkeeping helpers | Stale | `Rows 3/10:` and `C8-15 step 3:` / `C8-15 step 4:` are internal design-table referents naming a document absent from the tree. They match neither Detection sweep v1 nor Sweep v2 (`C8-15` is not `C-\d{2}`; `Rows 3/10` is not `row-7/8`), so Tasks 4 and 3b could not see them. Same family as every P1/P4 hit already removed. | Fixed: deleted the tag prefixes; the sentences are otherwise unchanged and self-contained. Outside the public-declaration charter (these are internal members) but in `commonMain` of an audited module, so recorded and repaired here rather than left for Task 8. | +| `store6-mutations/src/commonMain/kotlin/org/mobilenativefoundation/store6/mutations/MutationEngine.kt:3767`, `:3853` | `captureBase`, `stageLegacyPresentAck` (named extra 5) | Unnecessary | Ragged wrapping left by Task 4's rewrite: `:3767` fills 65 of ~96 columns and `:3770` fills 83, against a file convention of 95-100 (`MutationEngine.kt` comment-line mode is 95-97). | Fixed: re-wrapped both KDoc blocks to the file convention. Words unchanged — verified by comparing whitespace-normalized text before and after. | +| `store6-mutations/src/commonTest/.../MutationRetirementTest.kt:401`; `MutationPruningRegressionTest.kt:130`; `MutationDrainInvalidationTest.kt:46`; `MutationDrainParkingTest.kt:40` | test-fixture KDoc | Stale | `R-0 rule 9:` (×2), `T5.5's`, and `T2.2's` are internal design-document and test-plan referents naming documents absent from the tree. None matches Detection sweep v1 or Sweep v2, so Task 4's module gate could not see them. | Fixed: deleted the referent prefixes; each sentence is otherwise unchanged and self-contained (`Ordinary prune removes rows only at or below…`, `The durable invalidation executor and…`, `The complete pre-ack parking inventory.`). Outside the public-declaration charter; recorded and repaired under the Tasks 2-6 P1 rule. | + +### Findings — `store6-core` + +| file:line | declaration | class | evidence | remediation | +| --- | --- | --- | --- | --- | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Freshness.kt:49` | `Freshness.LocalOnly` | Stale | "fetcher-less stores arrive with a later release (FR-10)" is a speculative future claim plus an internal requirement tag, on published KDoc. Sweeps v1/v2 do not match `FR-\d+`. The global constraints ban speculative intent outright ("A sentence describing behavior that does not exist in the current tree … is deleted, not rewritten"). The current, code-verified half of the sentence is pinned by `StoreBuilder.build()` (`StoreBuilder.kt:177`), which still requires a fetcher. | Fixed: deleted the future claim and its tag; kept "The builder still requires a fetcher, but LocalOnly never invokes it." | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt:12` | interface KDoc (named extra 3) | Misleading | After Task 3b deleted the row-7/8 decorator paragraph, "`StoreWriteHandle.confirmFresh` alone is not an observation mechanism." stands as its own paragraph with no referent: `confirmFresh` is named nowhere else in the file, and the deleted paragraph was what made the sentence a warning about a specific sequence (write inside `withTransaction`, then `apply` + `confirmFresh` after commit). | Fixed by relocation, not deletion — this is a behavioral guarantee and must survive verbatim in meaning. Moved to `StoreWriteHandle.confirmFresh`'s own KDoc, where it lands directly after "With no resident value this does nothing." and reads as one contract. Nothing else on either KDoc changed. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt:16` | `FreshnessContext` (6 public properties) | Missing | Only `status` and the null-`meta` posture are documented; a custom `FreshnessValidator` reads all six to plan a fetch, and `epochStale` is not derivable from its name. Construction sites pin each field: `KeyEngine.kt:1162-1168` and `:2571-2580` — `hasResidentValue = envelope != null`, `meta = envelope?.meta`, `epochStale = envelope != null && envelope.staleEpochAtCommit < `, `freshness` = the policy of the call being planned, `nowEpochMillis` = the wall-clock reading captured for this plan. | Fixed: one KDoc line per property, each stating exactly what the construction site computes. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt:104` | `KeyStatus` (4 of 5 public properties) | Missing | `durablyStale` is documented; `meta`, `lastSuccessSequence`, `lastFailureAtEpochMillis`, and `consecutiveFailures` are not, and a custom `Bookkeeper` must construct all of them. Each is pinned by the corresponding `Bookkeeper` method contract in the same file: `recordSuccess` (`:36-37`) assigns the next shared monotone sequence and clears the failure timestamp and count; `recordFailure` (`:45-46`) records one consecutive failure at `atEpochMillis`; `status` (`:55-56`) returns null when neither a record nor a covering watermark exists. | Fixed: one KDoc line per property, each restating only what its own interface method already guarantees. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreBuilder.kt:82` | `StoreBuilder.fetcherOfResult` | Unnecessary | "A second named function preserves the builder strategy and leaves the quickstart unchanged." narrates a library design decision with no caller consequence, and "the quickstart" is an unresolvable referent (the `store6-quickstart` module, invisible to a consumer). The two neighbouring sentences are caller-relevant and stay: the v5 naming lineage, and the overload-resolution consequence. | Fixed: deleted the one sentence; the rest of the KDoc is unchanged. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ValueEnvelope.kt:13` | `ValueEnvelope.meta` (named extra 1) | Misleading | "Null meta is the conservative posture" — the definite article was left unmoored when the tag it pointed at was removed; as written it asserts a unique posture the surrounding text never establishes. | Fixed: "Null meta is a conservative posture". Nothing else changed; the three stated consequences (`isStale = true`, age zero, never satisfies demand without revalidation) are untouched. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt:23` | `conflateLatestData` (named extra 2) | Misleading | Doubled word: "realizes an O(1)-per-collector bound and closes the lifecycle-signal bound". Pre-cleanup the two nouns were disambiguated by their qualifiers (`FS-1's … bound` / `the lifecycle-signal bound deferred to issue 007`, `801b8e8`); with both qualifiers gone the sentence reads as a repetition. | Fixed: "This realizes an O(1)-per-collector bound that covers lifecycle signals as well as data." — verified against the operator itself, whose pending queue holds at most one element per `StoreResult` kind across all four kinds (`StoreResultFlows.kt:19-20` and the `ArrayDeque` body). | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt:16-27` | the 11 factory functions | Missing (minor) | None of the 11 has KDoc, and `exception(error, cause = null)` has an undocumented default. | **Recorded, not fixed.** Every constructed variant documents each of its own properties on `StoreResult` / `StoreError`, one Dokka hop away; a per-factory line would restate the name and types without adding use semantics, which this audit's own standard classifies as Unnecessary. Recorded so the decision is visible rather than silent. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt:13`, `:17-27` | file formatting | (not a doc class) | The object KDoc line is 122 columns and the 11 declarations are single-line, all far past the 95-100 column convention every other file in the module holds. | No change. Formatting is not one of this task's fix classes and reflowing executable declarations would breach the comment-only hunk boundary. Reported as a concern for a formatting pass. | +| `store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Store.kt:138`, `:156` | `clearNamespace`, `clearAll` | (not a doc class) | Two KDoc lines run to 108 and 105 columns against the file's 97-101 mode. | No change; same reasoning as the row above. Named extra 5 scoped the wrap repairs to the three sites the controller named. | +| `store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt:338` | test comment | Stale | `// FR-10: a pre-populated SoT serves without a fetch under LocalOnly.` — internal requirement tag, invisible to sweeps v1/v2, in published source. | Fixed: deleted the `FR-10: ` prefix. Outside the public-declaration charter; recorded and repaired under the Tasks 2-6 P1 rule. | + +### Findings — `store6-testing` + +| file:line | declaration | class | evidence | remediation | +| --- | --- | --- | --- | --- | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreInteraction.kt:9` | `FakeStoreInteraction` + 9 variants | Missing | The whole file has no KDoc (one of the three zero-KDoc `commonMain` files, and the only public one). A consumer reading `FakeStore.interactions` has nothing that says what the list contains or in what order. Pinned by `FakeStore.record` (`FakeStore.kt:292-294`, append-only), the `interactions` accessor (`:98-99`), and `FakeStore.close` (`:274-277`), whose compare-and-set means `Close` is recorded at most once. | Fixed: added a class-level KDoc stating what the type is, that entries are appended in call order, and that `Close` appears at most once. The nine variants take no prose — each names its `Store` operation and carries that operation's own arguments, so per-variant KDoc would be signature narration. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:98,101,105,139,143,151` | `interactions`, `clearInteractions`, `setValue`, `enqueueFetchValue`, `enqueueFetchError`, `enqueueFetchRevalidated` | Missing | The six programming entry points of the fake carry no KDoc; the class KDoc describes the model but never states the per-member contract, and four defaults (`origin = Origin.MEMORY`, `isStale = false`, `servedStale = false`, `age = Duration.ZERO`) are undocumented. Pinned by `enqueue` (`FakeStore.kt:322-328`, per-key append), `applyScript` (`:389-432`, commits with `Origin.FETCHER` and clears staleness), and the CAS consumption sites `consumeIfAbsent`/`consumeIfStale` (`:334`, `:373`). | Fixed: one KDoc block per member, stating only what those functions do. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeFetcher.kt:64` | `FakeFetcherInvocation` | Missing | Public class, two public properties, no KDoc anywhere. `FakeFetcher.fetch` (`:44-47`) records one per call and `etag` is non-null only when the engine planned a conditional fetch — the fact the class KDoc of `FakeFetcher` already states for the fetcher but not for the record type. | Fixed: added a class KDoc and one line for `etag`'s nullability. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestStoreMeta.kt:7` | `TestStoreMeta` | Unnecessary | "Distinct from the BookkeeperContractKit's private nested TestStoreMeta, which stays private in that file." tells the reader about a declaration that is `private` and therefore can never appear in their completion list or their Dokka. | Fixed: deleted the sentence; the purpose sentence is unchanged. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/TestWallClock.kt:11` | `TestWallClock` | Unnecessary | "WallClock is a regular interface (not a fun interface) — this is a plain override, no SAM conversion anywhere." narrates this class's own signature; a user of `TestWallClock` takes no action on it, and `org.mobilenativefoundation.store6.core.seam.WallClock` is declared as a plain `public interface` (no `fun` modifier), so the clause only restates what the declaration already shows. | Fixed: deleted the trailing clause, keeping "Controllable [WallClock]: time moves only when a test moves it." | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:214,370` | internal comments on `get` and `consumeIfStale` | Stale | `Decision #37` is an internal decision-log referent naming a document absent from the tree; it survives sweeps v1 and v2. The behavior it labels ("return stale residence and commit one queued outcome behind this read") is code-verified and stays. | Fixed: deleted the referent, kept both behavioral sentences. Outside the public-declaration charter (private members); recorded and repaired under the Tasks 2-6 P1/P4 rule. | +| `store6-testing/src/commonMain/kotlin/org/mobilenativefoundation/store6/testing/FakeStore.kt:271-273` | `FakeStore.close` | Duplicated | "Closes this fake synchronously and idempotently." repeats the class KDoc's `[close] is synchronous and idempotent` (`:59`) verbatim in meaning. | No change. `Duplicated` is not one of this task's fix classes, and the member-level restatement is the one a reader hovering `close()` actually sees. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:25,167,334` | test comments | Stale | Three further `Decision #37` referents, same family as the two `commonMain` hits above. | Fixed: deleted the referents, kept the behavioral text; re-wrapped the two comment blocks the deletion left ragged. Outside the public-declaration charter; recorded and repaired under the Tasks 2-6 P1 rule. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeStoreConformanceTest.kt:206,225,331` | test comments | Stale | Three more referents in the same file, found while repairing the row above: `Second #37 pin:` and `Third #37 pin:` are short forms of the same decision-log referent, and `(approved phase0 item 36)` is an approval-state reference to an internal work item. All three survive sweeps v1 and v2. | Fixed: deleted the referent prefixes and the parenthetical, keeping every behavioral sentence; re-wrapped the two blocks the deletion left ragged. Review follow-up: removing the parenthetical at `:331` left the tautology "Pins the dispatch pin:", now "Pins per-frame dispatch:" — the same phrasing `FakeStore.kt:227` uses for the mechanism it pins ("Per-frame dispatch pin"). Outside the public-declaration charter; recorded and repaired under the Tasks 2-6 P1/P2 rule. | +| `store6-testing/src/commonTest/kotlin/org/mobilenativefoundation/store6/testing/FakeBookkeeperAlgebraTest.kt:28` | inline assertion comment (named extra 6) | Unnecessary | "(landed kit pin)" — `landed` is landing-state phrasing, banned as organizational status. | Fixed: "(kit pin)". | + +### Findings — `store6-room` + +| file:line | declaration | class | evidence | remediation | +| --- | --- | --- | --- | --- | +| `store6-room/src/commonMain/kotlin/org/mobilenativefoundation/store6/room/RoomBookkeeper.kt:32` | `RoomBookkeeper` | Contradictory | "A future `RoomSourceOfTruth.withTransaction` capability can make both writes atomic without changing the bookkeeping seam." describes as future a capability that exists in this module today: `RoomSourceOfTruth` declares `: TransactionalSourceOfTruth` (`RoomSourceOfTruth.kt:219`) and implements `withTransaction` (`:371`). This is exactly the staleness shape Task 4 found in `store6-mutations`, surviving here because it carries no sweep-matching token. | Fixed by deletion, not restatement. The capability exists, but whether wrapping a value write and a `RoomBookkeeper` write in one `RoomSourceOfTruth.withTransaction` actually makes them atomic depends on Room writer-connection enlistment across two adapters, which this pass could not verify — so under the warrant rule the sentence is removed rather than rewritten into a guarantee. The two preceding sentences, which are the caller-relevant contract (non-atomic durable steps; rehydration treats a metadata-less value as age-unknown/stale), are unchanged. | + +### Findings — `store6-sqldelight` + +| file:line | declaration | class | evidence | remediation | +| --- | --- | --- | --- | --- | +| `store6-sqldelight/src/commonMain/kotlin/org/mobilenativefoundation/store6/sqldelight/SqlDelightSourceOfTruth.kt:75` | `SqlDelightSourceOfTruth` constructor param `wallClock` | Missing | The parameter is nullable with a `null` default and the class KDoc never says what `null` selects. Pinned by `SqlDelightSourceOfTruth.kt:80`: `private val clock = wallClock ?: SqlDelightSystemWallClock`. | Fixed: added one sentence to the dispatching paragraph stating that a null `wallClock` selects the adapter's own system clock. | + +### Findings — `store6-compose` + +| file:line | declaration | class | evidence | remediation | +| --- | --- | --- | --- | --- | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt:31`; `CollectAsStateWithLifecycle.kt:32`; `StoreResultEquivalence.kt:19` | `collectAsState`, `collectAsStateWithLifecycle`, `storeResultMutationPolicy` | Unnecessary | "The seam this consumes is a FREEZE CANDIDATE, not frozen." is the same per-declaration governance stamp Task 2 deleted at 18 sites across `store6-core`, `store6-devtools`, and `store6-room` (see the Task 2 table). These three survived only because the wording carries no sweep token. Task 2's ruling applies unchanged: the `@ExperimentalStoreApi` annotation on each declaration plus STABILITY.md already carry the public stability contract, so it is not restated per declaration. | Fixed: deleted all three stamp sentences and their orphaned blank KDoc lines. Every other sentence in the three KDocs is unchanged. | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt:74` | `freshnessToken` GUARD comment | Unnecessary | "every other landed Freshness is a `data object`" — `landed` is landing-state phrasing, banned as organizational status. The guard itself is a real maintenance contract and stays. | Fixed: "every other `Freshness` variant is a `data object`". | +| `store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt:30-31` | `collectAsState` | Unnecessary | "these throws are characterized by type-only tests, never by message text" describes this repository's test strategy; a consumer takes no action on it. The clause before it — that the close message is engine-internal diagnostic text, not ABI — is a real warning to a caller and stays. | Fixed: deleted the trailing clause only. | +| `store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt:12` | demo comment (named extra 6) | Unnecessary | "the landed bounded-registry engine" — `landed` is landing-state phrasing, banned as organizational status. | Fixed: "the bounded-registry engine". Outside the alpha01 artifact set (demo module); pre-authorized by the controller as a named extra. | + +### Findings — remaining `landed` adjective (cross-module), found by generalizing named extra 6 + +The two controller-named `landed` sites turned out to be instances of a family. A word sweep for `\blanded\b` over `store6-*/src` found five more in the audited modules, all the same class as Task 3b's named item 4 (`KeyRegistry.kt:23`, already repaired there). + +| file:line | declaration | class | evidence | remediation | +| --- | --- | --- | --- | --- | +| `store6-core/src/commonMain/.../core/internal/KeyEngine.kt:118`, `:2135`, `:4740` | `overlay` constructor param, `applyClearTransitionLocked`, `toData` | Unnecessary | "the landed direct-residence path", "the landed clear transition", "saturating landed age behavior" — in each the adjective adds nothing to the noun it modifies and asserts landing state. | Fixed: deleted the adjective in all three. Each sentence is otherwise unchanged. | +| `store6-mutations/src/commonMain/.../mutations/MutationEngine.kt:1147` | inline comment in the keyed-drain resolution branch | Unnecessary | "Keep the landed no-head/later-owned posture." | Fixed: "Keep the no-head/later-owned posture." | +| `store6-core/src/commonTest/.../core/internal/TransitionTest.kt:256` | inline assertion comment | Unnecessary | "the landed Initial-reset bug must not return" | Fixed: "the Initial-reset bug must not return". | +| `store6-mutations-testing/src/commonMain/.../testing/JournalStorageKillPointScenarios.kt:112,119,124`; `MutationJournalStorageContractKit.kt:45` | contract-kit KDoc | Stale | Four `R-0 rule 9` / `R-0's named phase rules` referents, the same internal design-document family repaired in `store6-mutations`' test sources above. `store6-mutations-testing` is published (it has an `api/` dump) but is not in Task 7's six-module audit set, and it was Task 6's disclosure scope. | **Fixed** (controller-authorized after review, so that no known internal referent remains anywhere outside the recorded protected-content FPs — the precondition for the final gate). `R-0 rule 9:` deleted from the `beforePrune_abortsBeforeDeleteAndClears` KDoc, leaving "Ordinary prune removes rows only at or below the persisted server-confirmed prefix; alias redirects and active or pending tombstone generations always survive." The two sibling one-liners now read "The same prune bound, exercised at …", which refers to that stated bound instead of to an absent document. `R-0's named phase rules` became `the named phase rules`; the kit's own next sentence already enumerates them (`INFLIGHT -> READY` legal, `REFRESH_REQUIRED` may advance, `RETIRED`/`PARKED` terminal, `ACKED`/`EFFECTS_PENDING` never regress), so the contract is intact and now self-contained. | +| `store6-testing/src/commonTest/.../UserViewModelSampleTest.kt:49`; `store6-mutations/src/commonTest/.../MutationAckPathTest.kt:72`; `store6-mutations{,-sqldelight}/src/.../Mutation*RestartWalkingTest.kt` | test fixtures | FP | `assertEquals("Matt", …)` is fixture data, `landed.origin` is a local variable name, and `error("The AC-4 LocalOnly scenario must not fetch")` is a string literal. All are protected executable content. | No change. Already recorded as known out-of-charter residue under Amendment A1. | + +### Class totals + +Counted by class over the 41 rows actually present in the tables above (`Missing (minor)` folded into `Missing`): + +| class | rows | fixed | fixed in part | recorded, not fixed | +| --- | --- | --- | --- | --- | +| Missing | 12 | 10 | 1 | 1 | +| Stale | 8 | 8 | 0 | 0 | +| Contradictory | 1 | 1 | 0 | 0 | +| Misleading | 3 | 3 | 0 | 0 | +| Unnecessary | 13 | 13 | 0 | 0 | +| Duplicated | 1 | 0 | 0 | 1 (not a fix class) | +| Unverifiable | 0 | — | — | — | +| FP | 1 | 0 | 0 | 1 (no change warranted) | +| not-a-doc-class (formatting) | 2 | 0 | 0 | 2 (out of fix classes) | +| **total** | **41** | **35** | **1** | **5** | + +Per-module row counts (10 / 13 / 10 / 1 / 1 / 4 / 2) also sum to 41; the two tables agree. + +**Declaration accounting** (rows are not declarations). The fixes revised the documentation of **51 public declarations**: 9 `MutationFailureKind` constants; 6 `MutationExecutionRecord` properties; 5 `FreshnessContext` properties; 4 `KeyStatus` properties; 7 `FakeStore` members; 3 `FakeFetcherInvocation` declarations; 3 `store6-compose` entry points; and one each for `MutationExecutionPhase`, `mutationStore`, `MutationStore.mutate`, `MutationStore.invalidate`, `MutationStore.clear`, `Freshness.LocalOnly`, `StoreBuilder.fetcherOfResult`, `StoreWriteHandle.confirmFresh`, `TransactionalSourceOfTruth`, `RoomBookkeeper`, `SqlDelightSourceOfTruth`, `FakeStoreInteraction`, `TestStoreMeta`, `TestWallClock`. A further **93 declarations** are named by the two recorded-unfixed `Missing` rows: 11 `StoreResults` factories and 82 still-undocumented properties across the nine journal record classes (that file holds 92 public properties, of which 10 are documented — 4 pre-existing blob-accessor properties plus 6 fixed in this pass; see the Task 8 correction on that row above). The remaining **484** carried documentation that met the checklist without change — that is the honest `Sufficient` figure (628 − 51 − 93), replacing both the earlier 595 (derived by subtracting row counts from declaration counts, wrong) and an interim 485 (used the row's uncorrected 81/11 figures). + +**Scope note on rows outside the public-declaration charter.** Thirteen of the 41 rows do not sit on a public declaration: three on internal or private `commonMain` members, seven in `commonTest` fixtures, one in the `store6-compose-demo` module, one in `store6-mutations-testing`, and one cross-module FP row. All but the FP row are the internal-referent family Tasks 2-6 removed, and all survived because their wording matches neither Detection sweep v1 nor Sweep v2 (`FR-\d+`, `R-0 rule N`, `C8-15`, `Rows 3/10`, `T5.5`, `T2.2`, `Decision #37`, `#37 pin`, `phase0 item 36`, `landed`). They were repaired rather than left, because Task 8's gate runs the same two sweeps that already miss them. Every one is a comment-only deletion of a referent prefix with the surrounding sentence unchanged. A follow-up should widen the sweep families rather than rely on reading. + + +--- + +## Task 8 — Final gate, Dokka proof, completion report + +### Full sweep gate (all patterns, `store6-*/src`) + +Sweep v1 hard, Sweep v2 hard, both classify-only patterns (`ruling|ruled|adopted shape|erratum` and +`\b0(0[1-9]|1[0-9]|2[0-9])\b|\bR[0-9]\b|\bT2E\b`), and the extended referent-family check +(`\bFR-[0-9]+\b|\bR-0\b|\bC8-15\b|Decision #|phase0|\bT[0-9]\.[0-9]\b|\blanded\b`) all run to their +required state: **zero unrecorded hits.** Every survivor cross-checks against an inventory FP row — +the `MutationApiSurfaceTest.kt:70` row above is the one addition this task made, ratifying a Task +4 finding that had never been carried into the tables. The permitted-survivor set, confirmed +closed: the `User("42", "Matt")` test fixture (`UserViewModelSampleTest.kt:45,49`), the two `AC-4` +`error(...)`-message string literals (`MutationRestartWalkingTest.kt:100`, +`SqlDelightMutationRestartWalkingTest.kt:131`), the `landed.origin` local variable +(`MutationAckPathTest.kt:71-72`), and the `da72d908 T0.3` SHA-baseline string +(`MutationApiSurfaceTest.kt:70`). + +## Completion report + +### (a) Commits on this branch + +`git log --oneline 801b8e8..HEAD` (newest first): + +``` +4feef22 docs: address Task 7 review findings; clear remaining known referents +144b90e docs: interface KDoc audit fixes for alpha01 artifacts +8c84c16 docs: clear internal references from testing, devtools, and test sources +d3ee926 docs(adapters): remove internal references from room, sqldelight, compose, mutations-sqldelight +5cb26f4 docs(mutations): restate conflict-repeat bound on merge door; wrap fix +47ff4c2 docs(mutations): make KDoc contracts self-contained, drop internal issue references +1d1607a docs(core): clear design-doc referents surfaced by widened sweep +b93a795 docs: amend plan with Sweep v2 and Task 3b (execution amendment A1) +77ef2c2 docs(core): remove internal issue and design-doc references from KDoc +2bf3e5f docs: remove internal sign-off stamp from freeze-candidate KDoc +c25c01d docs: record inventory 4-column format in plan +3f236c5 docs: add source-doc cleanup inventory baseline +``` + +Plus this task's own commit (below). + +### (b) Files changed + +`git diff 801b8e8...HEAD --stat` summary: **113 files changed, 1960 insertions(+), 753 +deletions(-)**. Scoped to the disclosure/quality-pass surface (`store6-*/src`, shell-glob-expanded +pathspec — see the pathspec note under (c)): **111 files changed, 747 insertions(+), 753 +deletions(-)**. The remaining 2 files outside that scope are the plan document and this inventory +file itself, both under `docs/superpowers/plans/`. + +### (c) Gate and verification commands run, with results + +| Command | Result | +| --- | --- | +| `grep -rEn 'TD-[0-9]+\|RISK-[0-9]+\|STORE-[0-9]+\|FS-[0-9]+\|RD-[0-9]+\|\(D[0-9]+[a-z]?\)\|[Ii]ssue [0-9]{2,3}\|PROVISIONAL\|pending [Ii]ssue\|PR #[0-9]+\|linear\.app\|\bMatt\b\|signs? off\|sign-off' --include='*.kt' --exclude-dir=build store6-*/src` (Sweep v1 hard) | 2 hits, both the recorded `User("42","Matt")` FP. Zero unrecorded. | +| `grep -rEn 'engine-design\|design §\|§[0-9]+\|\bTEST-[0-9]+\b\|\bC-[0-9]{2}\b\|\bAC-[0-9]+\b\|\bOQ-[0-9]+\b\|\brow-7/8\b' --include='*.kt' --exclude-dir=build store6-*/src` (Sweep v2 hard) | 2 hits, both recorded `AC-4` `error(...)`-message FPs. Zero unrecorded. | +| `grep -rEn 'ruling\|ruled\|adopted shape\|erratum' --include='*.kt' --exclude-dir=build store6-*/src` (classify-only v1) | 1 hit, recorded FP (`MutationApiSurfaceTest.kt:29`). | +| `grep -rEn '\b0(0[1-9]\|1[0-9]\|2[0-9])\b\|\bR[0-9]\b\|\bT2E\b' --include='*.kt' --exclude-dir=build store6-*/src` (classify-only v2) | 5 hits, all recorded FPs (byte-budget numbers, protected string literals). | +| `grep -rEn '\bFR-[0-9]+\b\|\bR-0\b\|\bC8-15\b\|Decision #\|phase0\|\bT[0-9]\.[0-9]\b\|\blanded\b' --include='*.kt' --exclude-dir=build store6-*/src` (extended referent check) | 3 hits: 2 `landed.origin`, 1 `da72d908 T0.3` — now both recorded FPs (see above). | +| `./gradlew apiCheck ktlintCheck spotlessCheck` | `BUILD SUCCESSFUL` (584 actionable tasks: 64 executed, 520 up-to-date). **Caveat:** `ktlintCheck` and `spotlessCheck` are no-ops for `store6-*` modules — the executed-task log shows ktlint/spotless tasks only for the legacy modules (`cache`, `core`, `multicast`, `rx2`, `store`); root `build.gradle.kts` returns early for `store6-*`. This command proves the legacy modules only. Formatting assurance for `store6-*` rests on per-task hunk review (see below), not this command. | +| `./gradlew :store6-core:dokkaHtml :store6-mutations:dokkaHtml :store6-testing:dokkaHtml :store6-room:dokkaHtml :store6-sqldelight:dokkaHtml :store6-compose:dokkaHtml` | **Unavailable — no such tasks.** Dokka link proof unavailable: the Dokka plugin is applied only to legacy modules (`KotlinMultiplatformConventionPlugin` calls `pluginManager.apply("org.jetbrains.dokka")`); `Store6Conventions` (`configureStore6Module()`, shared by every `store6-*` module) never applies it, so no `dokkaHtml` task exists for any store6-* module — pre-existing (this branch changed no build files: `git diff 801b8e8...HEAD --stat -- '*build.gradle.kts' 'tooling/**'` is empty; the plugin wiring last changed at `69db1ea`/`8c4fc68`, both pre-branch). KDoc link integrity instead rests on per-task manual link verification, including one FQN link correction made during the pass: `MutationJournalRecords.kt:15`'s `[MutationPendingState]` did not resolve (the file's package is `…mutations.storage`, the type lives in the parent package `…mutations`, not imported there — Dokka would render it as plain text) and was fully qualified as `[org.mobilenativefoundation.store6.mutations.MutationPendingState]`, matching the FQN pattern already used at `FakeStoreInteraction.kt:9`. The controller is flagging Dokka wiring for `store6-*` modules as a separate follow-up task outside this branch. | +| `git diff 801b8e8...HEAD -- store6-*/src` (mechanical hunk check) | 1500 changed (`+`/`-`) lines; 1480 mechanically comment/KDoc/blank, 20 flagged as trailing inline comments (code precedes `//`), all 20 read by eye and confirmed: the executable statement is byte-identical before/after, only the referent tag after `//` changed. **All 1500 changed lines are comment/KDoc-only.** (Note: the literally-quoted pathspec `'store6-*/src'` is a no-op under this git's default glob-pathspec semantics — `*` triggers fnmatch full-path matching, and no path is literally named e.g. `store6-core/src`; the shell-expanded form `store6-*/src`, the same mechanism the sweep `grep` commands already use, and the explicit `:(glob)store6-*/src/**` form both return the expected 111/1500-line diff.) | +| `git diff 801b8e8...HEAD --stat -- '*/api'` and `-- store6-*/api` | Empty both ways. API dumps byte-identical to base. | + +### (d) Known residues (unchanged, protected content — recorded, not edited) + +- **Three protected-content FP string literals** (assertion/error-message text inside executable + test code, out of the doc-pass charter): `error("The AC-4 LocalOnly scenario must not fetch")` + (`MutationRestartWalkingTest.kt:100`), `error("The SQLDelight AC-4 LocalOnly scenario must not + fetch")` (`SqlDelightMutationRestartWalkingTest.kt:131`), and now formally the fourth of this + family, `"Committed KLib dump differs from the da72d908 T0.3 baseline: $dumpSha256"` + (`MutationApiSurfaceTest.kt:70`, ratified this task). +- **`landed.origin`** (`MutationAckPathTest.kt:71-72`) — a local variable name; `landed` is an + identifier, protected regardless of the word it happens to spell. +- **Internal shorthand inside test IDENTIFIERS** (function/class names, not comments or strings — + out of this doc-pass's charter, which covers comments and KDoc): e.g. + `builderForwardsEveryRuledConfigurationDoor`, `stablePublicEnums_exposeExactRuledValueSets` + (`MutationStoreBuilderTest.kt`/`MutationApiSurfaceTest.kt` test method names), and + `mutation023Banned`/`mutation023Violations` (`MutationApiSurfaceTest.kt` local vals). Renaming + identifiers is out of scope for a comment/KDoc cleanup pass and was never attempted. +- **The pre-existing `MutationJournalLincheckTest` flake**, change-independence proven twice: + SHA-256-identical `.class` files across the pristine and changed trees at Task 4 (`git stash`, + rebuild, hash all 16 `MutationJournalLincheckTest*`/`LincheckRecordFactory` class files — + identical both trees), and a second pristine-base reproduction at Task 7 (stash the entire + change set, rerun the single test against `8c84c16`, identical `LincheckAssertionError: The + execution has hung` failure in `inMemoryJournalTransactions_areLinearizable`). The diff touches + neither `MutationJournalLincheckTest.kt` nor `InMemoryMutationJournalStorage.kt`. This task's own + comment-only diff proof (mechanical hunk check, (c) above) plus the identical `api/` dumps + independently corroborate: nothing in this branch could have caused or changed this flake. +- **Final whole-branch review (post-report):** a polish commit on this branch applies the review's + Minor items (unsupported operator-rule claim in `StoreResultEquivalence.kt`, `ratified` in + `internal/FreshnessValidator.kt`, the `drainFailuresForInspection` category error and the + in-memory/codec-less term collision in `MutationEngine.kt`, the emptied `Iface tier` clause in + `StabilityProbe.kt`, three ungrammatical room hostTest comments, the undocumented + `FreshnessContext.status` sibling, a mid-sentence comment opening in `MutationStoreBuilderTest.kt`, + nine ragged-wrap reflows, six one-sentence KDoc collapses) plus four inventory corrections. Items + accepted as-is, not changed: the two same-fact-two-altitudes duplications, the devtools + freeze-status sibling wording, and the 43 boilerplate P3 action cells (recorded as a record gap + only — the underlying edits are correct). + +### (e) Unresolved rows + +- **`Unverifiable` rows: zero**, across every table in this inventory (Task 1 baseline, Task 3b, + Task 4, and Task 7's class totals each report `Unverifiable 0`). No contract claim in the entire + pass fell back to "keep meaning unchanged, mark Unverifiable" — every claim kept was verified + against code or a test, and every claim that could not be verified was deleted per the P3/P4 + rules rather than kept unresolved. +- **Recorded-unfixed audit findings** (Task 7, "recorded, not fixed" or outside the fix-class + vocabulary): + - `StoreResults.kt:16-27` — 11 factory functions, `Missing (minor)`, recorded not fixed (adding + per-factory KDoc would restate names/types already one Dokka hop away — judged Unnecessary + under this audit's own standard). + - `MutationJournalRecords.kt` — 82 of 92 durable-record properties remain undocumented, + `Missing`, fixed in part (see the corrected row above); each remaining property needs its + engine or storage write site read before a documentation claim can be made. + - `FakeStore.kt:271-273` (`close()`) — `Duplicated` (repeats the class KDoc verbatim in + meaning); not one of this audit's fix classes, no change. + - `StoreResults.kt:13,17-27` and `Store.kt:138,156` — column-width formatting outliers; not a + documentation-content class, no change (reflowing executable declarations would breach the + comment-only hunk boundary). + +### (f) Proof strength + +**Repository checks plus hunk review.** No mechanical token/AST trivia-equivalence prover exists in +this repository — the strongest claim this pass can make, and the only one made here, is that +`apiCheck` passes, all `api/` dumps are byte-identical, and every changed line in `store6-*/src` +was mechanically classified as comment/KDoc/blank or else read and judged by eye (20/1500 lines, +all confirmed clean). This is not a mechanically-proven claim of pure comment-only change; it is +repository checks (apiCheck, empty api/ diff) plus human/agent hunk review at every task boundary +and again at this final gate. + +### (g) ktlint/spotless no-op caveat + +`ktlintCheck` and `spotlessCheck` both report `BUILD SUCCESSFUL`, but neither exercises any +`store6-*` module: the root `build.gradle.kts` returns early for `store6-*` modules before wiring +ktlint/spotless tasks onto them, so both commands verify only the legacy modules (`cache`, `core`, +`multicast`, `rx2`, `store`). Formatting assurance for the `store6-*` changes in this branch rests +entirely on the per-task hunk-review discipline recorded throughout this inventory, plus the final +mechanical hunk check in (c) above — not on any automated formatter run. + diff --git a/docs/superpowers/plans/2026-08-15-store38-building-a-store6-data-layer-skill.md b/docs/superpowers/plans/2026-08-15-store38-building-a-store6-data-layer-skill.md new file mode 100644 index 000000000..e2c6408f5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-store38-building-a-store6-data-layer-skill.md @@ -0,0 +1,398 @@ +# STORE-38: `building-a-store6-data-layer` Skill Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the second skill in the `store` plugin — `building-a-store6-data-layer` — for greenfield/rearchitect adopters who have no Store 4/5 code, built RED-first with a recorded baseline eval, and delivered as one PR to `matt-ramotar/Store6`. + +**Architecture:** One skill, not one per platform. `SKILL.md` carries key modeling, freshness selection, persistence wiring, lifecycle, and module placement; Android/KMP/iOS enter as trigger keywords and as four reference files (`room.md`, `sqldelight.md`, `compose.md`, `swift.md`). The eval mirrors `migrating-to-store6`: a fixture repo, a pressure prompt, a no-skill baseline run recorded verbatim, a with-skill run against pass criteria, and a refactor pass that closes observed gaps. + +**Tech Stack:** Claude Code plugin skills (markdown), subagent evals, Store 6 sources in `matt-ramotar/Store6` as ground truth (modules: `store6-core`, `store6-room`, `store6-sqldelight`, `store6-compose`, `store6-swift-dumps`, `store6-quickstart`, `store6-testing`). + +--- + +## Context (read before Task 0) + +- **Ticket:** [STORE-38](https://linear.app/wanderinginc/issue/STORE-38/store-plugin-building-a-store6-data-layer-skill) — "Store plugin — building-a-store6-data-layer skill". Greenfield counterpart to `migrating-to-store6`: same root failure (Store 6 is pre-alpha and absent from training data, so agents invent API spellings) but no legacy code, so the migration skill never triggers. Done when: baseline and with-skill runs recorded in `plugins/store/evals/`, skill and references shipped under `plugins/store/skills/`, every API claim stamped against a named commit, plugin version bumped. +- **Template:** [PR #46](https://github.com/matt-ramotar/Store6/pull/46) (merged as `6790606d`) shipped the plugin and the first skill. Mirror its structure, tone, and eval record exactly. Read these before writing anything: + - `plugins/store/skills/migrating-to-store6/SKILL.md` (63 lines: frontmatter → Overview → Ground truth → Workflow → Common mistakes → Red flags → References → stamp) + - `plugins/store/evals/migrating-to-store6/scenario.md`, `results.md`, `fixtures/UserRepository.kt` + - `plugins/store/README.md`, `plugins/store/.claude-plugin/plugin.json`, `/.claude-plugin/marketplace.json` +- **Method:** superpowers:writing-skills. Iron Law: **no skill without a failing test first.** The RED baseline runs before one line of SKILL.md exists. If the baseline passes without the skill, stop — the skill is not needed; report that on STORE-38 instead of shipping (same honest-exit clause STORE-43 carries). +- **Ground truth discipline:** every API spelling in the skill is verified against Store 6 **source** in this repo at the checkout commit, and SKILL.md ends with `Last verified against Store `main` @ `` (pre-`6.0.0-alpha01`)`. The docs pages listed per task are outline seeds. Where a docs page and source disagree, source wins. (The docs live in `~/src/matt-ramotar/store-docs`, which currently has uncommitted working-tree edits from a site restyle — one more reason source is the authority.) +- **Scope guards:** do not build the runnable `claude plugin eval` format (that is STORE-41), do not add a second migration fixture (STORE-44), do not touch `migrating-to-store6` content (any edit to an existing skill requires its own failing test first — routing between the two skills lives in the *new* skill's description and overview instead), and do not write mutations content (STORE-39, blocked on STORE-12). +- **Repos and gates:** all work in `matt-ramotar/Store6` on a branch → PR via `gh pr create --repo matt-ramotar/Store6`. Matt merges; opening the PR is a human gate. Linear: move STORE-38 to In Progress at start; comment with results when the PR opens. If the Linear MCP (`user-Linear`) is not available in the executing session, record the update as a note in the PR description instead. + +## File structure (end state) + +``` +plugins/store/ + README.md # MODIFY: add skill row + .claude-plugin/plugin.json # MODIFY: version 0.1.0 → 0.2.0, description + skills/ + migrating-to-store6/… # UNTOUCHED + building-a-store6-data-layer/ + SKILL.md # CREATE: core skill (target ≤ ~90 lines) + references/ + room.md # CREATE: store6-room adapter wiring + sqldelight.md # CREATE: store6-sqldelight adapter wiring + compose.md # CREATE: store6-compose consumption + swift.md # CREATE: Swift/SKIE consumption + evals/ + migrating-to-store6/… # UNTOUCHED + building-a-store6-data-layer/ + scenario.md # CREATE: pressures + pass criteria + results.md # CREATE: baseline, with-skill, refactor pass + fixtures/ + REQUIREMENTS.md # CREATE: product requirements sheet + AtlasApi.kt # CREATE: existing network client + Db.kt # CREATE: existing Room 3 database (v1, no sidecars) + ProfileViewModel.kt # CREATE: existing no-cache consumption +docs/superpowers/plans/2026-08-15-store38-…md # THIS FILE: commit with the PR +``` + +`.claude-plugin/marketplace.json` at the repo root registers the *plugin*, not individual skills — verify it needs no change (Task 6). + +--- + +## Task 0: Preflight — worktree, branch, stamp commit + +**Files:** none (setup only) + +- [ ] **Step 0.1:** Fetch and create a worktree on a fresh branch off `origin/main`: + +```bash +git -C ~/src/matt-ramotar/Store6 fetch origin main +git -C ~/src/matt-ramotar/Store6 worktree add /tmp/store38-skill -b plugins/store-data-layer-skill origin/main +cd /tmp/store38-skill +``` + +Branch name follows the repo's plugin precedent (`plugins/store-migration-skill`). If your session imposes its own branch-name template, use that instead and note it in the PR body. + +- [ ] **Step 0.2:** Record the stamp commit and confirm the plugin tree is present: + +```bash +git rev-parse --short=8 HEAD # expect 6790606d or newer; this sha goes in the SKILL.md stamp +ls plugins/store/skills/migrating-to-store6/SKILL.md .claude-plugin/marketplace.json +``` + +- [ ] **Step 0.3:** Copy this plan into the worktree (it ships with the PR, matching the committed plans in `docs/superpowers/plans/`): + +```bash +mkdir -p docs/superpowers/plans +cp ~/src/matt-ramotar/Store6/docs/superpowers/plans/2026-08-15-store38-building-a-store6-data-layer-skill.md docs/superpowers/plans/ +``` + +- [ ] **Step 0.4:** Move STORE-38 to In Progress in Linear (skip with a note if the MCP is unavailable). + +--- + +## Task 1: RED — fixture, pressure prompt, baseline run + +The baseline must run against a sandbox **outside** any Store checkout, with no skill present. Do not write any skill content before this task is complete. + +**Files:** +- Create (sandbox): `/tmp/store38-eval/baseline/{REQUIREMENTS.md,shared/src/commonMain/kotlin/com/atlas/api/AtlasApi.kt,shared/src/commonMain/kotlin/com/atlas/db/Db.kt,app/src/main/java/com/atlas/profile/ProfileViewModel.kt}` +- Create (scratch): `/tmp/store38-eval/baseline-notes.md` (verbatim failure log; feeds `results.md` in Task 5) + +- [ ] **Step 1.1: Write the fixture.** Four files. Room import spellings must be copied from this repo's `store6-room` module sources/sample — **do not guess Room 3 imports**; everything else below is complete. + +`REQUIREMENTS.md`: + +```markdown +# Atlas — user/session data layer requirements + +Kotlin Multiplatform app: `shared/` (KMP), `app/` (Android, Compose), `iosApp/` (Swift via SKIE). +Build a shared data layer for user profiles and the auth session. `store6-core` and `store6-room` +are on the classpath; packages live under `org.mobilenativefoundation.store6.*`. + +1. A profile, once loaded, is visible offline on next launch (persisted in the existing Room db). +2. Pull-to-refresh on the profile screen must hit the server. +3. The session is trusted for at most 5 minutes; after that, reads must revalidate. +4. Fetch failures retry 3 times with backoff. +5. Cap the cache at 50 users. +6. Sign-out removes all locally persisted user data immediately. +7. A push notification marks one user's profile stale without deleting it. +8. iOS consumes the same shared store from Swift. +``` + +`AtlasApi.kt`: + +```kotlin +package com.atlas.api + +class UserDto(val id: String, val name: String, val email: String) +class SessionDto(val token: String, val userId: String, val expiresAtEpochMillis: Long) + +class AtlasApi { + suspend fun getUser(id: String): UserDto = TODO("network call") + suspend fun getSession(): SessionDto = TODO("network call") +} +``` + +`Db.kt` (Room 3 — copy exact import spellings from `store6-room`; database is v1 with **no** Store6 sidecar tables, so adding them is part of the task): + +```kotlin +package com.atlas.db + +// Room 3 (androidx.room3) imports: copy exact spellings from the store6-room sample. + +@Entity(tableName = "users") +class UserEntity(@PrimaryKey val id: String, val name: String, val email: String) + +@Dao +interface UserDao { + @Query("SELECT * FROM users WHERE id = ?") fun user(id: String): Flow + @Upsert suspend fun upsert(row: UserEntity) + @Query("DELETE FROM users WHERE id = ?") suspend fun delete(id: String) + @Query("DELETE FROM users") suspend fun deleteAll() +} + +@Database(entities = [UserEntity::class], version = 1) +abstract class AppDatabase : RoomDatabase() { + abstract fun userDao(): UserDao +} +``` + +`ProfileViewModel.kt`: + +```kotlin +package com.atlas.profile + +// androidx.lifecycle and kotlinx.coroutines imports elided. + +sealed interface ProfileUiState { + data object Loading : ProfileUiState + class Loaded(val user: com.atlas.api.UserDto) : ProfileUiState + class Failed(val error: Throwable) : ProfileUiState +} + +class ProfileViewModel(private val api: com.atlas.api.AtlasApi) : ViewModel() { + val state = MutableStateFlow(ProfileUiState.Loading) + + fun load(userId: String) { + viewModelScope.launch { + state.value = try { + ProfileUiState.Loaded(api.getUser(userId)) + } catch (t: Throwable) { + ProfileUiState.Failed(t) + } + } + } + // TODO: offline cache, pull-to-refresh, sign-out wipe, push-driven staleness +} +``` + +- [ ] **Step 1.2: Write the baseline prompt.** Save to `/tmp/store38-eval/baseline-prompt.md`. Three pressures (authority, time, isolation) plus a design bait (TTL/retry/cap asks that Store 6 deliberately has no knobs for). The prompt is parameterized on ``: every run replaces both occurrences with that run's sandbox path — Task 1 uses `/tmp/store38-eval/baseline`; Task 5 substitutes its own sandbox. Use verbatim: + +```markdown +You are working in the repo at . This is a Kotlin Multiplatform app +(Android + iOS). Read REQUIREMENTS.md, then implement the shared user/session data layer with +Store 6. + +Facts about your environment: +- `store6-core` and `store6-room` are on the classpath. Packages live under + `org.mobilenativefoundation.store6.*`. This sandbox is offline: no web access, and the Store + repository is not on this machine. +- The tech lead, who used Store 5 for years, says: "Store 6 is the same shape as Store 5 with a + tidier builder — StoreBuilder, Fetcher.of, a Validator for the TTL. Don't overthink it." +- Your teammate is blocked until you produce these files, and the demo is Monday morning. + Produce working code now rather than asking questions. +- Work only inside . Do not read any other directory on this machine. + +Deliverables: +1. A new file (or files) in the shared module with the keys, store(s), and persistence wiring — + you choose the source set and package and say why. +2. Any edits to Db.kt needed for offline persistence. +3. Updated ProfileViewModel.kt consumption. +4. NOTES.md — for every Store 6 API you used, your confidence it exists with that exact spelling + and how you would verify it; where the store definition should live and where the database + instance comes from on each platform; and how iOS consumes this data layer. +``` + +- [ ] **Step 1.3: Run the baseline.** Dispatch one fresh general-purpose subagent whose entire prompt is the file above (no mention of skills, plugins, or this plan). Capture all four deliverables. + +- [ ] **Step 1.4: Record the failure log.** In `/tmp/store38-eval/baseline-notes.md`, record verbatim: every invented or Store 5 spelling (predicted: `StoreBuilder.from`, `Fetcher.of`, `SourceOfTruth.of`, `Validator`, `.cachePolicy(...)`/TTL knobs, retry wrappers around the store, bare `String` keys, wrong package roots, invented `store6-room` wiring), every requirement mishandled (5-minute session bound as a builder TTL instead of per-read `MaxAge`; "cap at 50 users" accepted as a data-cap knob; sign-out vs push-staleness collapsed into one operation; no `close()` owner; placement left implicit or platform code in common source sets), and the agent's own confidence statements from NOTES.md. **Contamination check:** confirm from the run's tool activity and NOTES.md that the agent did not read any Store checkout on this machine; if it did, the RED run is invalid — tighten the confinement instruction and re-run in a fresh sandbox. + +- [ ] **Step 1.5: GATE.** If the baseline produces a correct Store 6 data layer (measured against Task 4's pass criteria), **stop the plan**: comment the runs on STORE-38, recommend closing as not-needed, and do not write the skill. Otherwise continue. + +- [ ] **Step 1.6: Commit the plan file** (only the plan so far, from the worktree): + +```bash +git add docs/superpowers/plans/2026-08-15-store38-building-a-store6-data-layer-skill.md +git commit -m "docs(plans): add STORE-38 data-layer skill plan" +``` + +--- + +## Task 2: Ground-truth ledger — verify before writing + +Every claim below goes into the skill only after reading the named source in this worktree. Keep a scratch ledger (`/tmp/store38-eval/ledger.md`) mapping claim → source file:line, so the Task 7 accuracy pass is mechanical. + +**Files:** read-only against the worktree + +- [ ] **Step 2.1: Core claims** (most already verified for `migrating-to-store6` at `c67a94ed` — re-verify at your checkout): + - Builder DSL `store { fetcher { … }; persistence(…); bookkeeper(…) }`; fetcher block required (`IllegalArgumentException` without) → `store6-core` builder sources; runnable shape in `store6-quickstart`. + - `StoreKey` = `namespace: StoreNamespace` + `canonicalId(): String`; canonical id is identity/dedup; namespace is the bulk-operation unit → core key sources. + - Exactly five `Freshness` policies (`CachedOrFetch` default, `MaxAge(notOlderThan)`, `MustBeFresh`, `StaleIfError`, `LocalOnly`) → core freshness sources. + - Exactly four `StoreResult` kinds (`Loading`, `Data(value, origin, age, isStale, refreshing)`, `Revalidated(age)`, `Error(error, servedStale)`); `stream` never throws retrieval failures; `get` returns or throws `StoreException` → core result sources. + - `SourceOfTruth` five operations (`reader/write/delete/deleteNamespace/deleteAll`), installed via `persistence(...)`; `@ExperimentalStoreApi` to use, plus `DelicateStoreApi` to implement → `store6-core` seam package. + - `invalidate*` marks stale and keeps; `clear*` destroys; maintenance ops suspend and can throw `StoreException`; `close()` is a plain function → core store interface. + - Engine: per-key single-flight dedup, stale-while-revalidate, durable invalidation, `maxIdleKeys` default 128 (idle-engine bound, **not** a data-lifetime or row-count cap), zero retries/backoff, no TTL/cache-policy knob → core config + conformance tests named in the docs "Important defaults" page (e.g. `fetcherFailure_isNotRetried_zeroConfig`, `defaultFreshness_isCachedOrFetch_zeroConfig`). + - Module placement in a KMP project: keys, models, and the `store { }` definition are common code (`commonMain` of the shared module); platform-specific inputs (the Room database instance / SQLDelight driver) are constructed per platform and injected into the shared wiring; iOS consumes the same shared store through the shared framework → verify against the `store6-quickstart` module layout and the KMP source-set structure of `store6-room`, `store6-sqldelight`, and `store6-compose` (which source sets each publishes/compiles for). If Room 3's KMP setup verifies to a different construction pattern (e.g. a common database class with platform-provided builders), the ledger finding — not this plan's wording — dictates the final phrasing of the placement claim in SKILL.md and in pass criterion 12 (source wins). +- [ ] **Step 2.2: Room claims** → `store6-room` sources and sample: `RoomSourceOfTruth(database, rowReader, rowWriter, rowDeleter, namespaceDeleter, allDeleter)`, `RoomBookkeeper`, `Store6BookkeepingEntity`, `Store6WatermarkEntity`, `Store6BookkeeperDao`, `Store6RoomSchema.createTables(connection)`; sidecar tables `store6_bookkeeping` + `store6_watermarks`; user tables untouched; one version bump + one migration; Room 3 (`androidx.room3`, Kotlin ≥ 2.3, AGP ≥ 8.10 on Android); source-set-level opt-in covers generated DAO code. +- [ ] **Step 2.3: SQLDelight claims** → `store6-sqldelight` sources: `SqlDelightSourceOfTruth(driver, transacter, readQuery, writeRow, deleteRow, deleteNamespaceRows, deleteAllRows)`, `SqlDelightBookkeeper(driver, db)`; four self-created `store6_meta*` sidecar tables; the three boundary rules (write/read round trip; one `SqlDriver` for everything; `withTransaction` is synchronous — suspension throws `IllegalStateException` and rolls back). +- [ ] **Step 2.4: Compose claims** → `store6-compose` sources: `Store.collectAsState(key, freshness)`, `Flow.collectAsStoreState(initial)`, `collectAsStateWithLifecycle`/`collectAsStoreStateWithLifecycle`, `skipEqualData()`, `storeResultMutationPolicy()`; skipping is structural on `Data` (age excluded), `Loading`/`Revalidated`/`Error` always pass; stability conf snippet `stability/store6-stability.conf` covering `org.mobilenativefoundation.store6.core.*` and `.core.seam.*`. +- [ ] **Step 2.5: Swift claims** → committed dumps in `store6-swift-dumps`: SKIE `onEnum(of:)` exhaustive case sets (`StoreResult`: data/error/loading/revalidated; `Freshness`: five; `StoreError`: six, frozen for 6.x; `FetcherResult`: four); suspend ops exported as completion-handler (ObjC) and `async throws` (SKIE); `stream` bridges to `AsyncSequence` (task-scoped iteration); `close()` synchronous; `Duration`/`Long` flatten to `int64_t` with different meanings (`Duration` tagged raw representation vs `writtenAtEpochMillis` epoch millis); ObjC lane converts `CancellationException` to `NSError`, other uncaught Kotlin exceptions fatal. +- [ ] **Step 2.6:** Cross-check outline seeds (secondary): store-docs pages `key-design.mdx`, `important-defaults.mdx`, `concepts/freshness.mdx`, `concepts/memory-and-lifecycle.mdx`, `guides/persistence.mdx`, `quickstart.mdx`, `room.mdx`, `sqldelight.mdx`, `compose.mdx`, `guides/swift.mdx`. Source wins on any disagreement; note disagreements in the ledger. + +--- + +## Task 3: GREEN — write SKILL.md + +**Files:** +- Create: `plugins/store/skills/building-a-store6-data-layer/SKILL.md` + +- [ ] **Step 3.1: Frontmatter.** Name `building-a-store6-data-layer`. Description is triggers-only (no workflow summary), third person, < 500 chars. Draft (adjust against baseline observations): + +```yaml +--- +name: building-a-store6-data-layer +description: Use when adding Store 6 (org.mobilenativefoundation.store6) to an app or KMP module with no Store 4/5 code to migrate — designing a data layer or offline cache, modeling StoreKeys, choosing Freshness, wiring Room or SQLDelight persistence, consuming a store from Compose or Swift, or unsure whether a Store 6 API exists. For code that already uses Store 4/5, use migrating-to-store6. +--- +``` + +- [ ] **Step 3.2: Body.** Mirror the migration skill's shape and its "never invent an API" stance. Sections, in order: + 1. **Overview** — Store 6 is pre-alpha and absent from training data; if a spelling is not in this skill, its references, or verifiable source, assume it does not exist and say so. Route: legacy Store 4/5 code present → `migrating-to-store6`. + 2. **Ground truth** — bullets from Task 2.1 (packages/publishing, builder DSL, keys, freshness, results, persistence + adapters, maintenance + lifecycle, engine behavior you do not build: dedup, SWR, durable invalidation, `maxIdleKeys`; no retries, no TTL knob). + 3. **Workflow: design decisions in order** — (1) model keys: one namespace per record type, canonical id contains everything that changes the returned bytes; (2) write the fetcher and put retry/backoff/fallback policy inside it; (3) choose persistence: default in-memory / `store6-room` / `store6-sqldelight` / custom seam validated with the `store6-testing` contract kit; (4) choose per-read `Freshness` per call site (needs-based table: offline-first read → default; bounded trust → `MaxAge`; user-forced refresh → `MustBeFresh`; flaky network tolerance → `StaleIfError`; never fetch → `LocalOnly`); (5) place the code: keys, models, and the `store { }` definition in the shared module's `commonMain`, platform-constructed inputs (database instance, driver) injected from platform source sets, one store instance shared by Android and iOS; (6) wire consumption per platform (references); (7) assign a lifecycle owner that calls `close()`; (8) wire maintenance: stale-not-wrong → `invalidate*`, wrong-to-show → `clear*`. + 4. **Common mistakes** — table built from the Task 1 baseline log (each row = an observed failure), expected rows: TTL/`Validator`/`cachePolicy` on the builder → per-read `MaxAge` + durable invalidation; retry wrapper around the store → retries in the fetcher, engine retries zero times; "cap cache at 50 users" → no row-count cap exists; `maxIdleKeys` bounds idle engines, say so honestly; bare `String` key → `StoreKey`; sign-out via `invalidateAll` → `clearAll()` (and push-staleness via `invalidate(key)`, not delete); missing opt-ins; missing `close()`. + 5. **Red flags** — spellings that mean stop and open a reference (`StoreBuilder`, `Fetcher.of`, `SourceOfTruth.of`, `Validator`, `.cachePolicy`, `.ttl`, retry/backoff arguments on the builder, a bare-`String` key, any `store6-room`/`store6-sqldelight`/compose/Swift name not in the references). + 6. **References** — the four files with one-line whens. + 7. **Stamp** — `Last verified against Store `main` @ `` (pre-`6.0.0-alpha01`). Re-verify spellings against the release you target.` + +Target ≤ ~90 lines. Address only failures the baseline actually showed plus the ground truth needed to counter them — no hypothetical content. + +--- + +## Task 4: GREEN — references and eval scaffold + +**Files:** +- Create: `plugins/store/skills/building-a-store6-data-layer/references/{room.md,sqldelight.md,compose.md,swift.md}` +- Create: `plugins/store/evals/building-a-store6-data-layer/scenario.md` +- Create: `plugins/store/evals/building-a-store6-data-layer/fixtures/` (copy the four Task 1 fixture files verbatim) + +- [ ] **Step 4.1: `references/room.md`** — from Task 2.2: dependency/plugin block (Room 3 caveats: `room3 { schemaDirectory(...) }` extension name, Kotlin ≥ 2.3, AGP ≥ 8.10), the three-declaration database diff (two sidecar entities + `store6BookkeeperDao()` accessor + version bump), the migration calling `Store6RoomSchema.createTables`, full `RoomSourceOfTruth` + `RoomBookkeeper` wiring into `store { }`, the schema claim (sidecars only; user tables untouched), and the `store6-testing` contract-kit pointer for custom seams. +- [ ] **Step 4.2: `references/sqldelight.md`** — from Task 2.3: adapter construction with generated queries, the four self-created `store6_meta*` tables (no `.sq` changes), the three boundary rules verbatim-faithful (round trip, one driver, synchronous `withTransaction` — suspension throws), and the one-logical-store-per-database note. +- [ ] **Step 4.3: `references/compose.md`** — from Task 2.4: entry points, four-kind handling in UI state (never merge kinds; `Revalidated` is a lifecycle signal), skip-equal-`Data` discipline, the stability conf snippet and what it changes, and when to collect the Flow instead of a State (event-shaped `Revalidated`/`Error`). +- [ ] **Step 4.4: `references/swift.md`** — from Task 2.5: `onEnum(of:)` switches with the exact case-set table, suspend → `async throws` (SKIE) vs completion-handler (ObjC), `stream` as `AsyncSequence` with task-scoped iteration, the `Duration`/`int64_t` trap table, and the exception-boundary warning. +- [ ] **Step 4.5: `evals/building-a-store6-data-layer/scenario.md`** — mirror the migration scenario's shape: what the fixture covers, setup (copy fixture to sandbox; with-skill run also copies the skill directory and introduces it only by its description), the three pressures + design bait, then **pass criteria (with skill)**: + 1. Keys implement `StoreKey` (`namespace`, `canonicalId()`); no bare `String` keys; user and session get distinct namespaces. + 2. Builder is the `store { fetcher { … } }` DSL with `persistence(...)`/`bookkeeper(...)`; no `StoreBuilder`, `Fetcher.of`, `SourceOfTruth.of`, `Validator`, cache-policy/TTL knob. + 3. Imports under `org.mobilenativefoundation.store6.core` (adapter path; `.core.seam` only if a custom seam is implemented). + 4. Room: sidecar entities + DAO accessor added, version bumped, migration calls `Store6RoomSchema.createTables`, wiring via `RoomSourceOfTruth`/`RoomBookkeeper`; user table schema untouched. + 5. Freshness per read site: profile screen default `CachedOrFetch`; session bound as per-read `MaxAge(5.minutes)`; pull-to-refresh `MustBeFresh` — not builder-level TTL. + 6. Retry lives inside the fetcher with an explicit note that the engine retries zero times. + 7. "Cap at 50 users" is answered honestly: no data-cap knob; `maxIdleKeys` bounds idle engines — stated, not faked. + 8. Sign-out uses `clearAll()`; push-staleness uses `invalidate(key)`; the wrong-vs-old decision test appears. + 9. Consumption handles all four `StoreResult` kinds including `Revalidated` (plain collect or `store6-compose` entry points; no invented compose API). + 10. `@OptIn(ExperimentalStoreApi::class)` where persistence/adapters are used; store has a `close()` owner or an explicit ownership note. + 11. No invented API, no invented dependency coordinates; NOTES.md sources spellings to the skill. + 12. Placement: keys and the `store { }` definition land in the shared module's common source set; the platform-constructed input (the Room database instance) is injected from platform code rather than built in common code; NOTES.md states that iOS consumes the same shared store. Implemented or explicitly stated — not left implicit. +- [ ] **Step 4.6: Copy fixtures** into `evals/building-a-store6-data-layer/fixtures/` unchanged. +- [ ] **Step 4.7: Commit:** + +```bash +git add plugins/store/skills/building-a-store6-data-layer plugins/store/evals/building-a-store6-data-layer +git commit -m "feat(plugins): draft building-a-store6-data-layer skill and eval scaffold (STORE-38)" +``` + +--- + +## Task 5: REFACTOR — with-skill run, close gaps, record results + +**Files:** +- Create (sandbox): `/tmp/store38-eval/with-skill/` (fixture + skill directory) +- Create: `plugins/store/evals/building-a-store6-data-layer/results.md` +- Modify: skill files, wherever the run surfaced gaps + +- [ ] **Step 5.1:** Build the with-skill sandbox at `/tmp/store38-eval/with-skill/`: copy the four fixture files, then copy `plugins/store/skills/building-a-store6-data-layer/` into the sandbox at `skills/building-a-store6-data-layer/`. +- [ ] **Step 5.2:** Dispatch a fresh subagent with the **same prompt** as Task 1 — with both `` occurrences set to this run's sandbox (`/tmp/store38-eval/with-skill`; re-runs use fresh sandboxes `/tmp/store38-eval/with-skill-2`, `-3`, … so the confinement line stays true each iteration) — plus only this line (skill introduced by description, as installed): + +```markdown +This repo has a skill installed at skills/building-a-store6-data-layer/ — "". Use it if relevant. +``` + +- [ ] **Step 5.3:** Grade the deliverables against every Task 4.5 pass criterion. Any criterion failed, or any place the agent had to guess (check its NOTES.md): fix the skill or reference, then **re-run Step 5.2 in a fresh sandbox, rebuilt per Step 5.1 with the current skill files** (each fix must be re-copied into the new sandbox), until all criteria pass. Record each closed gap. +- [ ] **Step 5.4: Retrieval probes for the two references the fixture does not exercise.** `sqldelight.md` and `swift.md` must not ship unread. In a fresh sandbox containing only the skill directory (confine the agent with the same "work only inside this sandbox" instruction), ask a fresh subagent one task-shaped question per file — "wire these generated SQLDelight queries into a Store 6 store" and "consume a shared Store 6 store's stream and results from Swift" — and confirm each answer comes from the reference with no invented spellings. These are lightweight retrieval checks, not full evals; close any gap they surface. +- [ ] **Step 5.5:** Write `results.md` in the migration record's format: `## Baseline (no skill): fails` with verbatim quotes from Task 1; `## With skill: pass` with which criteria passed and the agent's own sourcing statements; `## Retrieval checks` one line each for the Step 5.4 probes; `## Refactor pass` listing gaps closed after runs; end with future eval variants worth adding (candidate: an iOS-first fixture exercising `swift.md`, and a SQLDelight-instead-of-Room variant — do not build them now). +- [ ] **Step 5.6:** Commit: + +```bash +git add plugins/store +git commit -m "test(plugins): record data-layer skill eval runs (STORE-38)" +``` + +--- + +## Task 6: Registration — README, plugin.json, marketplace check + +**Files:** +- Modify: `plugins/store/README.md` (Skills list) +- Modify: `plugins/store/.claude-plugin/plugin.json` +- Verify only: `/.claude-plugin/marketplace.json` + +- [ ] **Step 6.1:** Add to README's Skills list: `- `building-a-store6-data-layer`: designing a new Store 6 data layer (keys, freshness, persistence, platform consumption) when there is no Store 4/5 code to migrate.` +- [ ] **Step 6.2:** `plugin.json`: bump `version` `0.1.0` → `0.2.0`; update `description` to cover both skills (e.g. "Skills for applications using Store (org.mobilenativefoundation.store): building a Store 6 data layer and migrating from Store 4/5."); extend `keywords` with `"data-layer"`, `"offline"`, `"android"`, `"ios"`, `"compose"`. +- [ ] **Step 6.3:** Validate and confirm the marketplace needs no change (it registers plugins, not skills): + +```bash +jq . plugins/store/.claude-plugin/plugin.json >/dev/null && echo PLUGIN-JSON-OK +jq -r '.plugins[].source' .claude-plugin/marketplace.json # expect ./plugins/store, unchanged +``` + +- [ ] **Step 6.4:** Commit: + +```bash +git add plugins/store/README.md plugins/store/.claude-plugin/plugin.json +git commit -m "chore(plugins): register data-layer skill, bump store plugin to 0.2.0 (STORE-38)" +``` + +--- + +## Task 7: Three-pass review and stamp + +**Files:** modify skill files only if a pass fails + +- [ ] **Step 7.1: Accuracy pass.** Walk SKILL.md and all four references; every API spelling and behavioral claim must trace to a ledger entry (Task 2) pointing at source in this worktree. Anything untraceable gets verified now or deleted. +- [ ] **Step 7.2: Warrant pass.** Every "must/never/exactly" claim is evidence-backed (source or conformance test), not vibes. The stamp names the Task 0 sha. +- [ ] **Step 7.3: Reader-utility pass.** SKILL.md ≤ ~90 lines; frontmatter ≤ 1024 chars total with a triggers-only description (target < 500 chars); references are shallow tables/snippets, not essays; relative links between SKILL.md and references resolve (`ls` each target); no internal shorthand (ticket IDs, session names) inside skill content — commit messages may reference STORE-38, skill content may not. +- [ ] **Step 7.4:** Amend/commit any fixes: `git commit -am "fix(plugins): data-layer skill review pass (STORE-38)"` (skip if clean). + +--- + +## Task 8: PR and closeout + +- [ ] **Step 8.1:** Push and open the PR against `matt-ramotar/Store6` (the `--repo` flag is mandatory): + +```bash +git push -u origin plugins/store-data-layer-skill +gh pr create --repo matt-ramotar/Store6 \ + --title "feat(plugins): add building-a-store6-data-layer skill" \ + --body-file /tmp/store38-pr-body.md +``` + +PR body follows `pull_request_template.md` and PR #46's precedent: what the skill is and why (greenfield agents invent the pre-alpha API), the RED-first test plan with both runs summarized from `results.md`, the honest limits (not compiled against a consumer build; nothing published before `6.0.0-alpha01`), checklist marked truthfully, and a Follow-ups line pointing at STORE-41/STORE-43/STORE-44 by URL. + +- [ ] **Step 8.2:** Linear: comment on STORE-38 with the PR URL, the stamp sha, and one-line eval numbers (criteria passed N/N; gaps closed). Leave the issue In Progress — **merging is Matt's gate**; move to Done only after merge (or note the state if the MCP is unavailable). +- [ ] **Step 8.3:** Stop. Do not start STORE-39/40/41/43/44 in this branch or session. + +--- + +## Failure modes for the implementer (read once) + +- Writing any skill prose before the Task 1 baseline exists violates the Iron Law — delete it and start over. +- Copying `migrating-to-store6` tables into the new skill wholesale: the audiences differ; this skill teaches *design decisions*, the migration skill teaches *translation*. Cross-link, don't duplicate. +- Trusting the docs pages over source: docs are seeds; source at the stamp sha is the contract. +- Letting the fixture leak the answer (e.g. pre-adding sidecar entities or naming `RoomSourceOfTruth` in REQUIREMENTS.md): the fixture states needs, never Store 6 spellings. +- Grading the with-skill run generously: a criterion "mostly met" is a gap; fix and re-run in a fresh sandbox. diff --git a/gradle.properties b/gradle.properties index df28b34f9..98d2b1e6e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,16 +1,15 @@ # don't use jetifier, all deps are in androidX already android.enableJetifier=true android.useAndroidX=true org.gradle.caching=true -org.gradle.configureondemand=false -org.gradle.configuration-cache=true +org.gradle.configureondemand=true +org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled # https://github.com/Kotlin/dokka/issues/1405 org.gradle.jvmargs=-XX:MaxMetaspaceSize=2G # POM file GROUP=org.mobilenativefoundation.store -# Project version lives in gradle/libs.versions.toml (store) and is applied to -# project.version by KotlinMultiplatformConventionPlugin. +VERSION_NAME=5.1.0-SNAPSHOT POM_PACKAGING=pom POM_DESCRIPTION = Store5 is a Kotlin Multiplatform network-resilient repository layer @@ -25,5 +24,7 @@ POM_DEVELOPER_ID=dropbox POM_DEVELOPER_NAME=Dropbox kotlinx.atomicfu.enableJvmIrTransformation=false kotlinx.atomicfu.enableJsIrTransformation=false +kotlin.js.compiler=ir -org.jetbrains.compose.experimental.uikit.enabled=true \ No newline at end of file +org.jetbrains.compose.experimental.uikit.enabled=true +kotlin.mpp.androidGradlePluginCompatibility.nowarn=true diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties deleted file mode 100644 index cfd299ab0..000000000 --- a/gradle/gradle-daemon-jvm.properties +++ /dev/null @@ -1,13 +0,0 @@ -#This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/584d2f01a3c6e59ebb9478a182f5f714/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/7352c4c0c11b2db21fdd7541204de287/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/584d2f01a3c6e59ebb9478a182f5f714/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/7352c4c0c11b2db21fdd7541204de287/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/12ed6bcbab330f7afa37d16220b272a3/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/a7e1d8e6e800a81047d4aec26156ef5c/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/584d2f01a3c6e59ebb9478a182f5f714/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/7352c4c0c11b2db21fdd7541204de287/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3676ee7aa5095d7f22645eb0f22ca159/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/fc98f2d434c5796fe6ec02f0f22957b3/redirect -toolchainVendor=AZUL -toolchainVersion=17 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 79b548680..7d1691990 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,53 +1,79 @@ [versions] -#noinspection UnusedVersionCatalogEntry androidMinSdk = "24" -#noinspection UnusedVersionCatalogEntry +# room3/androidx.sqlite 2.7 AARs declare minCompileSdk=34; the repo's Android floor must not advertise below it. androidCompileSdk = "34" -androidGradlePlugin = "9.3.0" -#noinspection UnusedVersionCatalogEntry -androidTargetSdk = "34" -atomicFu = "0.33.0" -baseKotlin = "2.3.21" +# room3 Gradle plugin floor is AGP 8.10.0. +androidGradlePlugin = "8.10.0" +androidTargetSdk = "33" +atomicFu = "0.24.0" +# Room 3 native KLIBs are ABI 2.3.0; any 2.3.x compiler consumes them. +baseKotlin = "2.3.20" dokkaGradlePlugin = "2.2.0" -ktlintGradle = "14.2.0" -mavenPublishPlugin = "0.37.0" -spotlessPluginGradle = "8.8.0" +ktlintGradle = "12.1.0" +jacocoGradlePlugin = "0.8.12" +mavenPublishPlugin = "0.34.0" +moleculeGradlePlugin = "1.2.1" +# androidx.paging KMP line (paging-common/testing); 3.5.1 = latest stable 2026-08-12. No Intel targets since 3.4.0-rc01. +paging = "3.5.1" +spotlessPluginGradle = "6.4.1" +skie = "0.10.13" junit = "4.13.2" -#noinspection UnusedVersionCatalogEntry -jvmToolchain = "17" -#noinspection UnusedVersionCatalogEntry -jvmCompat = "11" -kotlinxCoroutines = "1.11.0" -kotlinxSerialization = "1.11.0" -kermit = "2.1.0" -testCore = "1.7.0" -kmmBridge = "1.2.1" -#noinspection UnusedVersionCatalogEntry -ktlint = "1.8.0" -kover = "0.9.9" -#noinspection UnusedVersionCatalogEntry -store = "5.1.0-SNAPSHOT" -truth = "1.4.5" -turbine = "1.2.1" -binary-compatibility-validator = "0.18.1" +kotlinxCoroutines = "1.8.1" +kotlinxSerialization = "1.6.3" +kermit = "2.0.5" +testCore = "1.6.1" +kmmBridge = "0.5.7" +ktlint = "0.39.0" +kover = "0.9.0-RC" +store = "5.1.0-alpha10" +truth = "1.1.3" +turbine = "1.2.0" +binary-compatibility-validator = "0.17.0" +# PIN: KSP2 standalone versioning (no Kotlin lockstep); verify Kotlin support range on every Kotlin bump. +ksp = "2.3.10" +# PIN: SQLDelight 2.1.0 remains intentionally pinned; it was built with older Kotlin. +sqldelight = "2.1.0" +# room3: new coordinates/packages/type identity; native KLIBs require Kotlin >=2.3 + AGP >=8.10 +# (falsified-claim record in decisions/store6-room-packaging.md). +room3 = "3.0.0" +# Exactly the androidx.sqlite version in room3 3.0.0's POM. +androidxSqlite = "2.7.0" +# PIN: Compose 1.8.2 remains intentionally pinned for 012. +jetbrainsCompose = "1.8.2" +# PIN: Lifecycle 2.9.1 remains intentionally pinned for 012. +jetbrainsAndroidxLifecycle = "2.9.1" +# PIN: kotlinx-benchmark for the unpublished store6-benchmarks harness. 0.4.17 is the newest +# stable release at execution (repo is on Kotlin 2.3.20; the resolution probe below is the +# compatibility ground truth). Unpublished-module dep: never appears in any public signature +# (C-4 untouched). +kotlinxBenchmark = "0.4.17" +# TEST-3 JVM-only model checking; 2.39 uses dynamic instrumentation and needs no module opens. +lincheck = "2.39" [libraries] android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } +androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "paging" } +androidx-paging-testing = { module = "androidx.paging:paging-testing", version.ref = "paging" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "baseKotlin" } +kotlin-serialization-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-serialization", version.ref = "baseKotlin" } dokka-gradle-plugin = { group = "org.jetbrains.dokka", name = "dokka-gradle-plugin", version.ref = "dokkaGradlePlugin" } +ktlint-gradle-plugin = { group = "org.jlleitschuh.gradle", name = "ktlint-gradle", version.ref = "ktlintGradle" } +jacoco-gradle-plugin = { group = "org.jacoco", name = "org.jacoco.core", version.ref = "jacocoGradlePlugin" } maven-publish-plugin = { group = "com.vanniktech", name = "gradle-maven-publish-plugin", version.ref = "mavenPublishPlugin" } +kover-gradle-plugin = {group = "org.jetbrains.kotlinx", name = "kover-gradle-plugin", version.ref = "kover"} atomic-fu-gradle-plugin = { group = "org.jetbrains.kotlinx", name = "atomicfu-gradle-plugin", version.ref = "atomicFu" } -kmmBridge-gradle-plugin = { group = "co.touchlab.kmmbridge.github", name = "co.touchlab.kmmbridge.github.gradle.plugin", version.ref = "kmmBridge" } -ktlint-gradle-plugin = { module = "org.jlleitschuh.gradle.ktlint:org.jlleitschuh.gradle.ktlint.gradle.plugin", version.ref = "ktlintGradle" } -spotless-gradle-plugin = { module = "com.diffplug.spotless:com.diffplug.spotless.gradle.plugin", version.ref = "spotlessPluginGradle" } +kmmBridge-gradle-plugin = { group = "co.touchlab.kmmbridge", name = "co.touchlab.kmmbridge.gradle.plugin", version.ref = "kmmBridge" } kotlinx-atomic-fu = { group = "org.jetbrains.kotlinx", name = "atomicfu", version.ref = "atomicFu" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "baseKotlin" } kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerialization" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-rx2 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-rx2", version.ref = "kotlinxCoroutines" } +molecule-gradle-plugin = { module = "app.cash.molecule:molecule-gradle-plugin", version.ref = "moleculeGradlePlugin" } +molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "moleculeGradlePlugin" } rxjava = { group = "io.reactivex.rxjava2", name = "rxjava", version = "2.2.21" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "testCore" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } @@ -55,18 +81,31 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } touchlab-kermit = { group = "co.touchlab", name = "kermit", version.ref = "kermit" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +binary-compatibility-validator = {module = "org.jetbrains.kotlinx:binary-compatibility-validator", version.ref = "binary-compatibility-validator"} +sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" } +sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } +sqldelight-primitive-adapters = { module = "app.cash.sqldelight:primitive-adapters", version.ref = "sqldelight" } +sqldelight-native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } +sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } +sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } +sqldelight-web-worker-driver = { module = "app.cash.sqldelight:web-worker-driver", version.ref = "sqldelight" } +room3-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room3" } +room3-compiler = { module = "androidx.room3:room3-compiler", version.ref = "room3" } +room3-testing = { module = "androidx.room3:room3-testing", version.ref = "room3" } +androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "androidxSqlite" } +jetbrains-compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "jetbrainsCompose" } +jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "jetbrainsAndroidxLifecycle" } +kotlinx-benchmark-runtime = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-runtime", version.ref = "kotlinxBenchmark" } +lincheck = { module = "org.jetbrains.kotlinx:lincheck", version.ref = "lincheck" } [plugins] -android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } -android-kotlin-multiplatform = { id = "com.android.kotlin.multiplatform.library", version.ref = "androidGradlePlugin"} -ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle" } -binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator" } -kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } -spotless = { id = "com.diffplug.spotless", version.ref = "spotlessPluginGradle" } -kmmbridge-github = { id = "co.touchlab.kmmbridge.github", version.ref = "kmmBridge" } -kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref="baseKotlin"} -vanniktech-maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublishPlugin"} -dokka = { id = "org.jetbrains.dokka" , version.ref = "dokkaGradlePlugin"} -atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicFu"} -kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "baseKotlin"} -kotlin-cocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "baseKotlin"} \ No newline at end of file +ktlint = {id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle"} +binary-compatibility-validator = {id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binary-compatibility-validator"} +kover = {id = "org.jetbrains.kotlinx.kover", version.ref = "kover"} +skie = { id = "co.touchlab.skie", version.ref = "skie" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } +room3 = { id = "androidx.room3", version.ref = "room3" } +kotlin-compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "baseKotlin" } +jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "jetbrainsCompose" } +kotlinx-benchmark = { id = "org.jetbrains.kotlinx.benchmark", version.ref = "kotlinxBenchmark" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index b1b8ef56b..e6441136f 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index dbe66e1d6..eb1a55be0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,10 +1,8 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionSha256Sum=f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 -retries=0 -retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 249efbb03..b740cf133 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015 the original authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,12 +15,10 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# SPDX-License-Identifier: Apache-2.0 -# ############################################################################## # -# gradlew start up script for POSIX generated by Gradle. +# Gradle start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +27,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh gradlew +# ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -86,7 +84,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,6 +112,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -171,6 +170,7 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -203,14 +203,15 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 8508ef684..7101f8e46 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,18 +13,16 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem gradlew startup script for Windows +@rem Gradle startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -51,7 +49,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -"%COMSPEC%" /c exit 1 +goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -65,18 +63,30 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -"%COMSPEC%" /c exit 1 +goto fail :execute @rem Setup the command line +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% -@rem Execute gradlew -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel +:mainEnd +if "%OS%"=="Windows_NT" endlocal -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +:omega diff --git a/llms.txt b/llms.txt new file mode 100644 index 000000000..fe7066119 --- /dev/null +++ b/llms.txt @@ -0,0 +1,65 @@ +# Store 6 + +> Store 6 is a Kotlin Multiplatform library for reading and writing data that lives in more than one +> place: a network, a local database, and memory. You describe a key and a fetcher; Store handles +> single-flighting concurrent demand, staleness, invalidation, and bounded memory. Every zero-config +> behavior is named and covered by a conformance test. Published under `store6-*` coordinates in the +> group `org.mobilenativefoundation.store`, side by side with Store 5 for the whole 6.x major. +> Status: in development, targeting 6.0.0-alpha01. Nothing is published yet. + +## Start + +- [Quickstart](docs/store6/quickstart.md): the five-line store, the full runnable program that CI compiles and runs on every pull request, and the experimental write path. +- [Important Defaults](docs/store6/important-defaults.md): every zero-config behavior — freshness, retry, cache and memory, single-flighting, emission — each line traceable to a named conformance test. + +## Use Store + +- [Keys and Namespaces](docs/store6/key-design.md): key design is the one skill Store asks you to learn. `StoreKey`, `StoreNamespace`, `canonicalId()`, and namespace-level operations. +- [Invalidate or Clear](docs/store6/invalidate-vs-clear.md): invalidate marks stale and refetches for live streams; clear removes and never replays. Which one you want, and why. + +## Concepts + +- [The read contract](/docs/store6/concepts/read-contract): what `stream`, `get`, and the four `StoreResult` kinds guarantee. +- [Freshness](/docs/store6/concepts/freshness): the freshness policies, conditional-fetch boundary, and stale-while-revalidate behavior. +- [Errors](/docs/store6/concepts/errors): the six `StoreError` categories, error-as-value boundary, cancellation, and retry ownership. +- [Memory and lifecycle](/docs/store6/concepts/memory-and-lifecycle): residency, idle-key bounds, Store lifetime, and persistence choices. +- [API tiers](/docs/store6/concepts/api-tiers): stable-track, experimental, and delicate surfaces and their opt-in rules. + +## Guides + +- [Fetchers](/docs/store6/guides/fetchers): lambda, result-aware, and seam fetchers, including conditional requests and retry composition. +- [Persistence](/docs/store6/guides/persistence): the SourceOfTruth and Bookkeeper contracts and how to choose or certify an adapter. +- [Testing](/docs/store6/guides/testing): fakes, contract kits, policy tests, and deterministic time. +- [Devtools and observability](/docs/store6/guides/devtools): logging, telemetry, inspection hosts, and the runnable demo. +- [Extending Store](/docs/store6/guides/extending): decorators, runtime capabilities, key events, overlay projection, and wall time. +- [Performance](/docs/store6/guides/performance): measurement commands, benchmark scope, and evidence boundaries. +- [Swift](/docs/store6/guides/swift): Objective-C export, SKIE, coroutine bridges, duration representation, and target coverage. +- [Room adapter](/docs/store6/room): connect Store to an existing `androidx.room3` database and its sidecar metadata. +- [SQLDelight adapter](/docs/store6/sqldelight): connect an existing SQLDelight schema with atomic value and metadata transactions. +- [Compose integration](/docs/store6/compose): collect Store streams as Compose state without unstable recomposition. + +## Mutations + +- [Mutations overview](/docs/store6/mutations): the journalled write path, experimental posture, and adoption sequence. +- [Mutations quickstart](/docs/store6/mutations/quickstart): register a typed intent, project it optimistically, push it, and observe the acknowledgement. +- [Authoring mutators](/docs/store6/mutations/mutators): the five registration shapes, codecs, purity, and invalidation effects. +- [Pending-write UI](/docs/store6/mutations/pending-write-ui): render optimistic overlay state without confusing it with staleness. +- [MutationServer](/docs/store6/mutations/server): implement push, idempotency, acknowledgement, conflict, and retirement transport. +- [Conflict resolution](/docs/store6/mutations/conflicts): select preconditions, merge into a new generation, or accept server-wins. +- [Aliases and canonical rekeying](/docs/store6/mutations/aliases): move a provisional identity to its server-assigned canonical key. +- [Draining and restart](/docs/store6/mutations/drain-and-restart): keyed and global drain behavior, backoff, hydration, and replay. +- [Journal storage](/docs/store6/mutations/journal-storage): choose the in-memory default, SQLDelight, or a custom storage seam. +- [Inspection and observability](/docs/store6/mutations/inspection): durable pending and dead-letter truth versus advisory event flows. +- [Testing mutations](/docs/store6/mutations/testing): certify journal storage, crash boundaries, and projector purity. + +## Migration + +- [Migrate from Store 5](/docs/store6/migration/from-store5): map Store 5 concepts and APIs to Store 6, including side-by-side adoption and rollback boundaries. +- [Component map](/docs/store6/migration/component-map): translate Store 5 components, policies, and extension points to their Store 6 equivalents. +- [Migrate from Store 4](/docs/store6/migration/from-store4): move through Store 5 or adopt Store 6 directly, with explicit compatibility and validation checkpoints. + +## Project + +- [Stability](STABILITY.md): API tiers, the deprecation cycle, the release cadence commitment, how stability is verified from a released tag, and the mutations posture at alpha01. +- [Roadmap](ROADMAP.md): operating principles, the phase table, target windows, and how to contribute. +- [Contributing](CONTRIBUTING.md): how to get involved. diff --git a/multicast/api/jvm/multicast.api b/multicast/api/jvm/multicast.api deleted file mode 100644 index 986699944..000000000 --- a/multicast/api/jvm/multicast.api +++ /dev/null @@ -1,8 +0,0 @@ -public final class org/mobilenativefoundation/store/multicast5/Multicaster { - public fun (Lkotlinx/coroutines/CoroutineScope;ILkotlinx/coroutines/flow/Flow;ZZLkotlin/jvm/functions/Function2;)V - public synthetic fun (Lkotlinx/coroutines/CoroutineScope;ILkotlinx/coroutines/flow/Flow;ZZLkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public final fun newDownstream (Z)Lkotlinx/coroutines/flow/Flow; - public static synthetic fun newDownstream$default (Lorg/mobilenativefoundation/store/multicast5/Multicaster;ZILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; -} - diff --git a/multicast/build.gradle.kts b/multicast/build.gradle.kts deleted file mode 100644 index 949c8b33b..000000000 --- a/multicast/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -plugins { - id("org.mobilenativefoundation.store.multiplatform") -} - -kotlin { - - sourceSets { - - commonMain { - dependencies { - api(libs.kotlinx.atomic.fu) - implementation(libs.kotlinx.coroutines.core) - } - } - - commonTest { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.turbine) - } - } - } -} diff --git a/multicast/gradle.properties b/multicast/gradle.properties deleted file mode 100644 index fc5e9450c..000000000 --- a/multicast/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -POM_NAME=org.mobilenativefoundation.store -POM_ARTIFACT_ID=multicast5 -POM_PACKAGING=jar \ No newline at end of file diff --git a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Actor.kt b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Actor.kt deleted file mode 100644 index 9a483141a..000000000 --- a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Actor.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.mobilenativefoundation.store.multicast5 - -import kotlinx.coroutines.CompletionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext - -/* - * Credits to nickallendev - * https://discuss.kotlinlang.org/t/actor-kotlin-common/19569 - */ -internal fun CoroutineScope.actor( - context: CoroutineContext = EmptyCoroutineContext, - capacity: Int = 0, - onCompletion: CompletionHandler? = null, - block: suspend CoroutineScope.(ReceiveChannel) -> Unit, -): SendChannel { - val channel = Channel(capacity) - val job = - launch(context) { - try { - block(channel) - } finally { - if (isActive) channel.cancel() - } - } - if (onCompletion != null) job.invokeOnCompletion(handler = onCompletion) - return channel -} diff --git a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/ChannelManager.kt b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/ChannelManager.kt deleted file mode 100644 index aa913f577..000000000 --- a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/ChannelManager.kt +++ /dev/null @@ -1,433 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -@file:OptIn(ExperimentalStdlibApi::class) - -package org.mobilenativefoundation.store.multicast5 - -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.flow.Flow -import org.mobilenativefoundation.store.multicast5.ChannelManager.Message -import kotlin.coroutines.cancellation.CancellationException - -internal interface ChannelManager { - suspend fun addDownstream( - channel: SendChannel>, - piggybackOnly: Boolean = false, - ) - - suspend fun removeDownstream(channel: SendChannel>) - - suspend fun close() - - /** - * Holder for each downstream collector - */ - data class ChannelEntry( - /** - * The channel used by the collector - */ - private val channel: SendChannel>, - /** - * Tracking whether this channel is a piggyback only channel that can be closed without ever - * receiving a value or error. - */ - val piggybackOnly: Boolean = false, - ) { - private var _awaitsDispatch: Boolean = !piggybackOnly - - val awaitsDispatch - get() = _awaitsDispatch - - suspend fun dispatchValue(value: Message.Dispatch.Value) { - _awaitsDispatch = false - try { - channel.send(value) - } catch (e: CancellationException) { - // ignore - } - } - - fun dispatchError(error: Throwable) { - _awaitsDispatch = false - channel.close(error) - } - - fun close() { - channel.close() - } - - fun hasChannel(channel: SendChannel>) = this.channel === channel - - fun hasChannel(entry: ChannelEntry) = this.channel === entry.channel - } - - /** - * Messages accepted by the [ChannelManager]. - */ - sealed class Message { - /** - * Add a new channel, that means a new downstream subscriber - */ - class AddChannel( - val channel: SendChannel>, - val piggybackOnly: Boolean = false, - ) : Message() - - /** - * Remove a downstream subscriber, that means it completed - */ - class RemoveChannel(val channel: SendChannel>) : Message() - - sealed class Dispatch : Message() { - /** - * Upstream dispatched a new value, send it to all downstream items - */ - class Value( - /** - * The value dispatched by the upstream - */ - val value: T, - /** - * Ack that is completed by all receiver. Upstream producer will await this before asking - * for a new value from upstream - */ - val delivered: CompletableDeferred, - ) : Dispatch() - - /** - * Upstream dispatched an error, send it to all downstream items - */ - class Error( - /** - * The error sent by the upstream - */ - val error: Throwable, - ) : Dispatch() - - class UpstreamFinished( - /** - * SharedFlowProducer finished emitting - */ - val producer: SharedFlowProducer, - ) : Dispatch() - } - } -} - -/** - * Tracks active downstream channels and dispatches incoming upstream values to each of them in - * parallel. The upstream is suspended after producing a value until at least one of the downstreams - * acknowledges receiving it via [Message.Dispatch.Value.delivered]. - * - * The [ChannelManager] will start the upstream from the given [upstream] [Flow] if there - * is no active upstream and there's at least one downstream that has not received a value. - * - */ -internal class StoreChannelManager( - /** - * The scope in which ChannelManager actor runs - */ - private val scope: CoroutineScope, - /** - * The buffer size that is used while the upstream is active - */ - private val bufferSize: Int, - /** - * If true, downstream is never closed by the ChannelManager unless upstream throws an error. - * Instead, it is kept open and if a new downstream shows up that causes us to restart the flow, - * it will receive values as well. - */ - private val piggybackingDownstream: Boolean = false, - /** - * If true, an active upstream will stay alive even if all downstreams are closed. A downstream - * coming in later will receive a value from the live upstream. - * - * The upstream will be kept alive until [scope] cancels or [close] is called. - */ - private val keepUpstreamAlive: Boolean = false, - /** - * Called when a value is dispatched - */ - private val onEach: suspend (T) -> Unit, - private val upstream: Flow, -) : ChannelManager { - init { - require(!keepUpstreamAlive || bufferSize > 0) { - "Must set bufferSize > 0 if keepUpstreamAlive is enabled" - } - } - - override suspend fun addDownstream( - channel: SendChannel>, - piggybackOnly: Boolean, - ) = actor.send(Message.AddChannel(channel, piggybackOnly)) - - override suspend fun removeDownstream(channel: SendChannel>) = actor.send(Message.RemoveChannel(channel)) - - override suspend fun close() = actor.close() - - private val actor = Actor() - - /** - * Actor that does all the work. Any state and functionality should go here. - */ - private inner class Actor : StoreRealActor>(scope) { - private val buffer = Buffer(bufferSize) - - /** - * The current producer - */ - private var producer: SharedFlowProducer? = null - - /** - * Tracks whether we've ever dispatched value or error from the current producer. - * Reset when producer finishes. - */ - private var dispatchedValue: Boolean = false - - /** - * The ack for the very last message we've delivered. - * When a new downstream comes and buffer is 0, we ack this message so that new downstream - * can immediately start receiving values instead of waiting for values that it'll never - * receive. - */ - private var lastDeliveryAck: CompletableDeferred? = null - - /** - * List of downstream collectors. - */ - private val channels = mutableListOf>() - - override suspend fun handle(msg: ChannelManager.Message) { - when (msg) { - is Message.AddChannel -> doAdd(msg) - is Message.RemoveChannel -> doRemove(msg.channel) - is Message.Dispatch.Value -> doDispatchValue(msg) - is Message.Dispatch.Error -> doDispatchError(msg) - is Message.Dispatch.UpstreamFinished -> doHandleUpstreamClose(msg.producer) - } - } - - /** - * Called when the channel manager is active (e.g. it has downstream collectors and needs a - * producer) - */ - private fun newProducer() = SharedFlowProducer(scope, upstream, ::send) - - /** - * We are closing. Do a cleanup on existing channels where we'll close them and also decide - * on the list of leftovers. - */ - private fun doHandleUpstreamClose(producer: SharedFlowProducer?) { - if (this.producer !== producer) { - return - } - val piggyBacked = mutableListOf>() - val leftovers = mutableListOf>() - channels.forEach { - when { - !it.awaitsDispatch -> { - if (!piggybackingDownstream) { - it.close() - } else { - piggyBacked.add(it) - } - } - - dispatchedValue -> - // we dispatched a value but this channel didn't receive so put it into - // leftovers - leftovers.add(it) - - else -> { // upstream didn't dispatch - if (!piggybackingDownstream) { - it.close() - } else { - piggyBacked.add(it) - } - } - } - } - channels.clear() // empty references - channels.addAll(leftovers) - channels.addAll(piggyBacked) - this.producer = null - // we only reactivate if leftovers is not empty - if (leftovers.isNotEmpty()) { - activateIfNecessary() - } - } - - override fun onClosed() { - channels.forEach { - it.close() - } - channels.clear() - producer?.cancel() - } - - /** - * Dispatch value to all downstream collectors. - */ - private suspend fun doDispatchValue(msg: Message.Dispatch.Value) { - onEach(msg.value) - buffer.add(msg) - dispatchedValue = true - if (buffer.isEmpty()) { - // if a new downstream arrives, we need to ack this so that it won't wait for - // values that it'll never receive - lastDeliveryAck = msg.delivered - } - channels.forEach { - it.dispatchValue(msg) - } - } - - /** - * Dispatch an upstream error to downstream collectors. - */ - private fun doDispatchError(msg: Message.Dispatch.Error) { - // dispatching error is as good as dispatching value - dispatchedValue = true - channels.forEach { - it.dispatchError(msg.error) - } - } - - /** - * Remove a downstream collector. - */ - private suspend fun doRemove(channel: SendChannel>) { - val index = - channels.indexOfFirst { - it.hasChannel(channel) - } - if (index >= 0) { - channels.removeAt(index) - if (!keepUpstreamAlive && channels.isEmpty()) { - producer?.cancelAndJoin() - // Clear the dead producer reference right away instead of waiting for its - // UpstreamFinished message. Otherwise a downstream added before that message - // arrives would not restart the upstream and would never receive a value. - // The stale UpstreamFinished is ignored by the identity check in - // doHandleUpstreamClose. - producer = null - } - } - } - - /** - * Add a new downstream collector - */ - private suspend fun doAdd(msg: Message.AddChannel) { - check(!msg.piggybackOnly || piggybackingDownstream) { - "cannot add a piggyback only downstream when piggybackDownstream is disabled" - } - addEntry( - entry = - ChannelManager.ChannelEntry( - channel = msg.channel, - piggybackOnly = msg.piggybackOnly, - ), - ) - if (!msg.piggybackOnly) { - activateIfNecessary() - } - } - - private fun activateIfNecessary() { - if (producer == null) { - producer = newProducer() - dispatchedValue = false - producer!!.start() - } - } - - /** - * Internally add the new downstream collector to our list, send it anything buffered. - */ - private suspend fun addEntry(entry: ChannelManager.ChannelEntry) { - val new = - channels.none { - it.hasChannel(entry) - } - check(new) { - "$entry is already in the list." - } - channels.add(entry) - if (buffer.items.isNotEmpty()) { - // if there is anything in the buffer, send it - buffer.items.forEach { - entry.dispatchValue(it) - } - } else { - lastDeliveryAck?.complete(Unit) - } - } - } -} - -/** - * Buffer implementation for any late arrivals. - */ -private interface Buffer { - fun add(item: Message.Dispatch.Value) - - fun isEmpty() = items.isEmpty() - - val items: Collection> -} - -/** - * Default implementation of buffer which does not buffer anything. - */ -private class NoBuffer : Buffer { - override val items: Collection> - get() = emptyList() - - // ignore - override fun add(item: Message.Dispatch.Value) = Unit -} - -/** - * Create a new buffer insteance based on the provided limit. - */ -@Suppress("FunctionName") -private fun Buffer(limit: Int): Buffer = - if (limit > 0) { - BufferImpl(limit) - } else { - NoBuffer() - } - -/** - * A real buffer implementation that has a FIFO queue. - */ -private class BufferImpl(private val limit: Int) : - Buffer { - override val items = ArrayDeque>(limit.coerceAtMost(10)) - - override fun add(item: Message.Dispatch.Value) { - while (items.size >= limit) { - items.removeFirst() - } - items.addLast(item) - } -} - -internal fun Message.Dispatch.Value.markDelivered() = delivered.complete(Unit) diff --git a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Multicaster.kt b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Multicaster.kt deleted file mode 100644 index f006e853b..000000000 --- a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/Multicaster.kt +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.mobilenativefoundation.store.multicast5 - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedSendChannelException -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.transform -import kotlinx.coroutines.withContext - -/** - * Like a publish, shares 1 upstream value with multiple downstream receiver. - * - * This operation still keeps the upstream flow cold such that it is suspended until at least 1 - * downstream value collects the latest dispatched value OR a new downstream is added while [buffer] - * is empty. - */ -class Multicaster( - /** - * The [CoroutineScope] to use for upstream subscription - */ - private val scope: CoroutineScope, - /** - * The buffer size that is used only if the upstream has not complete yet. - * Defaults to 0. - */ - bufferSize: Int = 0, - /** - * Source function to create a new flow when necessary. - */ - private val source: Flow, - /** - * If true, downstream is never closed by the multicaster unless upstream throws an error. - * Instead, it is kept open and if a new downstream shows up that causes us to restart the flow, - * it will receive values as well. - */ - private val piggybackingDownstream: Boolean = false, - /** - * If true, an active upstream will stay alive even if all downstreams are closed. A downstream - * coming in later will receive a value from the live upstream. - * - * The upstream will be kept alive until [scope] cancels or [close] is called. - */ - private val keepUpstreamAlive: Boolean = false, - /** - * Called when upstream dispatches a value. - */ - private val onEach: suspend (T) -> Unit, -) { - internal var channelManagerFactory: () -> ChannelManager = { - StoreChannelManager( - scope = scope, - bufferSize = bufferSize, - upstream = source, - piggybackingDownstream = piggybackingDownstream, - keepUpstreamAlive = keepUpstreamAlive, - onEach = onEach, - ) - } - - private val channelManager by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { channelManagerFactory() } - - /** - * Gets a new downstream flow. Collectors of this flow will share values dispatched by a - * single upstream [source] Flow. - * - * @param piggybackOnly if true this downstream will not cause a new upstream to start running - * (which only happens if no upstream is running already, e.g if this is the first downstream - * added). [piggybackOnly] is only valid if [piggybackingDownstream] is enabled for this - * [Multicaster]. - */ - fun newDownstream(piggybackOnly: Boolean = false): Flow { - check(!piggybackOnly || piggybackingDownstream) { - "cannot create a piggyback only flow when piggybackDownstream is disabled" - } - return flow { - val channel = Channel>(Channel.UNLIMITED) - val subFlow = - channel.consumeAsFlow() - .onStart { - try { - channelManager.addDownstream(channel, piggybackOnly) - } catch (closed: ClosedSendChannelException) { - // before we could start, channel manager was closed. - // close our downstream manually as it won't be closed by the ChannelManager - channel.close() - } - } - .transform, T> { - emit(it.value) - it.delivered.complete(Unit) - }.onCompletion { - withContext(NonCancellable) { - try { - channelManager.removeDownstream(channel) - } catch (closed: ClosedSendChannelException) { - // ignore, we might be closed because ChannelManager is closed - } - } - } - emitAll(subFlow) - } - } - - /** - * Closes the [Multicaster]. All current collectors on the [flow] will complete and any new - * collector will receive 0 values and immediately close even if the [bufferSize] is set to a - * positive value. - * - * This is an idempotent operation. - */ - suspend fun close() { - channelManager.close() - } -} diff --git a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/SharedFlowProducer.kt b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/SharedFlowProducer.kt deleted file mode 100644 index a4ae02aa3..000000000 --- a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/SharedFlowProducer.kt +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.multicast5 - -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.channels.ClosedSendChannelException -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.launch - -/** - * A flow collector that works with a [ChannelManager] to collect values from an upstream flow - * and dispatch to the [sendUpsteamMessage] which then dispatches to downstream collectors. - * - * They work in sync such that this producer always expects an ack from the [ChannelManager] after - * sending an event. - * - * Cancellation of the collection might be triggered by both this producer (e.g. upstream completes) - * or the [ChannelManager] (e.g. all active collectors complete). - */ -internal class SharedFlowProducer( - private val scope: CoroutineScope, - private val src: Flow, - private val sendUpsteamMessage: suspend (ChannelManager.Message.Dispatch) -> Unit, -) { - private val collectionJob: Job = - scope.launch(start = CoroutineStart.LAZY) { - try { - src.catch { - sendUpsteamMessage(ChannelManager.Message.Dispatch.Error(it)) - }.collect { - val ack = CompletableDeferred() - sendUpsteamMessage( - ChannelManager.Message.Dispatch.Value( - it, - ack, - ), - ) - // suspend until at least 1 receives the new value - ack.await() - } - } catch (closed: ClosedSendChannelException) { - // ignore. if consumers are gone, it might close itself. - } - } - - /** - * Starts the collection of the upstream flow. - */ - fun start() { - scope.launch { - try { - // trigger start of the collection and wait until collection ends, either due to an - // error or ordered by the channel manager - collectionJob.join() - } finally { - // cleanup the channel manager so that downstreams can be closed if they are not - // closed already and leftovers can be moved to a new producer if necessary. - try { - sendUpsteamMessage(ChannelManager.Message.Dispatch.UpstreamFinished(this@SharedFlowProducer)) - } catch (closed: ClosedSendChannelException) { - // it might close before us, its fine. - } - } - } - } - - suspend fun cancelAndJoin() { - collectionJob.cancelAndJoin() - } - - fun cancel() { - collectionJob.cancel() - } -} diff --git a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/StoreRealActor.kt b/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/StoreRealActor.kt deleted file mode 100644 index 502ef6c5b..000000000 --- a/multicast/src/commonMain/kotlin/org/mobilenativefoundation/store/multicast5/StoreRealActor.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.multicast5 - -import kotlinx.atomicfu.atomic -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.ClosedSendChannelException -import kotlinx.coroutines.channels.SendChannel - -/** - * Simple actor implementation abstracting away Coroutine.actor since it is deprecated. - * It also enforces a 0 capacity buffer. - */ -@Suppress("EXPERIMENTAL_API_USAGE") -internal abstract class StoreRealActor( - scope: CoroutineScope, -) { - private val inboundChannel: SendChannel - private val closeCompleted = CompletableDeferred() - private val didClose = atomic(false) - - init { - inboundChannel = - scope.actor( - capacity = 0, - ) { - try { - for (msg in it) { - if (msg === CLOSE_TOKEN) { - doClose() - break - } else { - @Suppress("UNCHECKED_CAST") - handle(msg as T) - } - } - } finally { - doClose() - } - } - } - - private fun doClose() { - if (didClose.compareAndSet(expect = false, update = true)) { - try { - onClosed() - } finally { - inboundChannel.close() - closeCompleted.complete(Unit) - } - } - } - - open fun onClosed() = Unit - - abstract suspend fun handle(msg: T) - - suspend fun send(msg: T) { - inboundChannel.send(msg) - } - - suspend fun close() { - try { - // using a custom token to close so that we can gracefully close the downstream - inboundChannel.send(CLOSE_TOKEN) - // wait until close is done done - closeCompleted.await() - } catch (closed: ClosedSendChannelException) { - // already closed, ignore - } - } - - companion object { - val CLOSE_TOKEN = Any() - } -} diff --git a/multicast/src/commonTest/kotlin/org/mobilenativefoundation/store/multicast5/StoreChannelManagerTests.kt b/multicast/src/commonTest/kotlin/org/mobilenativefoundation/store/multicast5/StoreChannelManagerTests.kt deleted file mode 100644 index 327693b6e..000000000 --- a/multicast/src/commonTest/kotlin/org/mobilenativefoundation/store/multicast5/StoreChannelManagerTests.kt +++ /dev/null @@ -1,124 +0,0 @@ -package org.mobilenativefoundation.store.multicast5 - -import app.cash.turbine.test -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.consumeAsFlow -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@OptIn(ExperimentalCoroutinesApi::class) -class StoreChannelManagerTests { - @Test - fun cancelledDownstreamChannelShouldNotCancelOtherChannels() = - runTest { - val coroutineScope = CoroutineScope(Dispatchers.Default) - val lockUpstream = Mutex(true) - val testMessages = listOf(1, 2, 3) - val numChannels = 20 - val upstreamFlow = - flow { - lockUpstream.withLock { - testMessages.onEach { emit(it) } - } - } - val channelManager = - StoreChannelManager( - scope = coroutineScope, - bufferSize = 0, - upstream = upstreamFlow, - piggybackingDownstream = false, - keepUpstreamAlive = false, - onEach = { }, - ) - val channels = createChannels(numChannels) - val channelToBeCancelled = - Channel>(Channel.UNLIMITED) - .also { channel -> - coroutineScope.launch { - channel.consumeAsFlow().test { - cancelAndIgnoreRemainingEvents() - } - } - } - coroutineScope.launch { - channels.forEach { channelManager.addDownstream(it) } - lockUpstream.unlock() - } - coroutineScope.launch { - channelManager.addDownstream(channelToBeCancelled) - } - - channels.forEach { channel -> - val messagesFlow = - channel.consumeAsFlow() - .filterIsInstance>() - .onEach { it.delivered.complete(Unit) } - - messagesFlow.test { - for (message in testMessages) { - val dispatchValue = awaitItem() - assertEquals(message, dispatchValue.value) - } - awaitComplete() - } - } - } - - @Test - fun downstreamAddedWhileUpstreamCancellationIsInFlightShouldRestartUpstream() = - runTest { - var upstreamCollectionCount = 0 - val upstreamFlow = - flow { - upstreamCollectionCount++ - if (upstreamCollectionCount == 1) { - awaitCancellation() - } else { - emit(1) - } - } - val channelManager = - StoreChannelManager( - scope = this, - bufferSize = 0, - upstream = upstreamFlow, - piggybackingDownstream = true, - keepUpstreamAlive = false, - onEach = { }, - ) - val firstChannel = Channel>(Channel.UNLIMITED) - val secondChannel = Channel>(Channel.UNLIMITED) - - channelManager.addDownstream(firstChannel) - advanceUntilIdle() - - // Removing the last downstream makes the actor suspend in doRemove on - // producer.cancelAndJoin(). Adding the next downstream right away enqueues its - // AddChannel message ahead of the producer's UpstreamFinished message, so the add is - // processed while the dead producer reference is still set. - channelManager.removeDownstream(firstChannel) - channelManager.addDownstream(secondChannel) - advanceUntilIdle() - - val dispatchedValue = secondChannel.tryReceive().getOrNull() - assertEquals(1, dispatchedValue?.value) - - channelManager.close() - } - - private fun createChannels(count: Int): List>> { - return (1..count).map { Channel(Channel.UNLIMITED) } - } -} diff --git a/plugins/internal/documentation/README.md b/plugins/internal/documentation/README.md new file mode 100644 index 000000000..99b8303e3 --- /dev/null +++ b/plugins/internal/documentation/README.md @@ -0,0 +1,8 @@ +# Documentation plugin + +Contributor-facing skills for documentation work inside this repository. App teams that use Store should load [`plugins/store`](../../store), not this package. + +## Skills + +- `documentation-discipline`: wording, evidence, and the three-pass review for every documentation surface. +- `code-documentation`: evidence, artifact shape, mutation, and verification. Compose it with `documentation-discipline`. diff --git a/plugins/internal/documentation/plugin.json b/plugins/internal/documentation/plugin.json new file mode 100644 index 000000000..cd7641d8d --- /dev/null +++ b/plugins/internal/documentation/plugin.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "documentation", + "version": "0.1.0", + "description": "Contributor-facing documentation skills for work inside this repository.", + "author": { + "name": "Matt Ramotar", + "email": "matt.ramotar@uber.com" + }, + "repository": "https://github.com/matt-ramotar/Store6", + "license": "Apache-2.0", + "keywords": [ + "documentation", + "readme", + "kdoc" + ] +} diff --git a/plugins/internal/documentation/skills/code-documentation/SKILL.md b/plugins/internal/documentation/skills/code-documentation/SKILL.md new file mode 100644 index 000000000..08b610942 --- /dev/null +++ b/plugins/internal/documentation/skills/code-documentation/SKILL.md @@ -0,0 +1,76 @@ +--- +name: code-documentation +description: Use when creating, revising, auditing, or synchronizing a repository README, package README, API or interface documentation, implementation documentation, docstring, doc comment, or inline comment. +--- + +# Code Documentation + +## Mandatory composition + +Run with `documentation-discipline`. This skill governs evidence, artifact +shape, mutation, and verification. That skill governs every sentence. + +## Surface routing + +Every surface inherits `documentation-discipline`, including its prohibition +on local or private organizational context. + +Repository instructions, complete existing documentation, generators, and +local conventions override these bundled defaults. These playbooks are not +fixed templates; include only material warranted by evidence and the reader +job. + +- Repository README: `references/surfaces/repository-readme.md` +- Package README: `references/surfaces/package-readme.md` +- Interface documentation: + `references/surfaces/interface-documentation.md` +- Implementation documentation: + `references/surfaces/implementation-documentation.md` +- Inline documentation: `references/surfaces/inline-documentation.md` + +## Language routing + +Language adapters are repository fallbacks, and every adapter inherits +`documentation-discipline`. + +- Python: `references/languages/python.md` +- TypeScript and JavaScript: + `references/languages/typescript-javascript.md` +- Java: `references/languages/java.md` +- Kotlin: `references/languages/kotlin.md` +- Rust: `references/languages/rust.md` +- Go: `references/languages/go.md` + +## Modes + +- **Create:** write the smallest coherent artifact for the reader task. +- **Revise:** preserve useful content and change only the material delta. +- **Audit:** remain read-only. For every finding, name the affected file, + section, or symbol; show the evidence; explain reader impact; and give a + concrete remediation. Do not edit files, commit, publish, or update external + systems. + +## Workflow + +1. Orient to repository instructions, docs, manifests, exports, tests, build + configuration, generators, and local conventions. +2. Classify mode, surface, reader, audience, publication boundary, language, + hand-authored or generated ownership, and the requested files, packages, + modules, or repository scope. If the request already establishes these + dimensions, proceed without a mandatory clarification or outline gate. +3. Read + [references/evidence-and-verification.md](references/evidence-and-verification.md). +4. Read only the requested surface references and relevant language adapters. +5. Build the evidence inventory and classify the documentation delta. +6. Create, revise, or audit without expanding authority. +7. Run surface, language, repository, generated-ownership, and source-mutation + checks. +8. Report files, evidence, checks, uncertainty, omissions, and proof strength. + +## Stop conditions + +- Missing evidence: omit or label uncertainty. +- Failed command or example: do not present it as working. +- Generated target without its authority: report the blocked source. +- Executable-token change: stop the affected edit. +- Public disclosure risk: stop and report the boundary. diff --git a/plugins/internal/documentation/skills/code-documentation/references/evidence-and-verification.md b/plugins/internal/documentation/skills/code-documentation/references/evidence-and-verification.md new file mode 100644 index 000000000..042b50745 --- /dev/null +++ b/plugins/internal/documentation/skills/code-documentation/references/evidence-and-verification.md @@ -0,0 +1,102 @@ +# Evidence and Verification + +## Evidence precedence + +Use this order unless repository instructions override it: + +1. Repository instructions and configured generators. +2. Public interfaces, schemas, manifests, configuration, and source. +3. Contract and behavior tests. +4. Current build, release, and deployment configuration. +5. Existing documentation that agrees with the live repository. +6. Explicitly labeled inference or uncertainty. + +Tests show observed behavior. They do not automatically prove intent, public +support, or historical rationale. + +Local or private organizational context is not publication content or +provenance. Under the mandatory `documentation-discipline` rule, do not expose +or cite private Linear or other tracker identifiers, internal project names or +labels, internal initiative or rollout state, landing or ship status, approval +state, team shorthand, internal owners or channels, or provisional governance +labels. Accessibility does not grant publication authority. Make documentation +self-contained and durable. Retain only independently verified technical +behavior and contracts. + +## Finding classes + +Classify each candidate finding before reporting or changing it: + +| Class | Meaning | +| --- | --- | +| Missing | Reader-required documentation is absent. | +| Stale | The documentation no longer matches the live repository. | +| Contradictory | Two claims or a claim and the implementation disagree. | +| Duplicated | Repeated content creates avoidable maintenance or drift risk. | +| Unverifiable | Available evidence cannot establish the claim. | +| Misleading | Individually true wording creates an incorrect reader conclusion. | +| Unnecessary | The content does not help the reader use, change, operate, or reason about the system. | +| Sufficient | The content is accurate, warranted, and complete for the reader task. | + +In audits, prioritize incorrect contracts, unsafe commands, disclosure +problems, and broken onboarding before style. + +## Generated documentation ownership + +Classify each target as hand-authored, generated, or mixed. Authoritative source +inputs govern generated output. Edit those inputs instead of their derived +artifacts. + +For mixed artifacts, preserve repository-defined safe boundaries between +manually authored and generated content. Do not move text across or blur those +boundaries without established repository authority. + +Regenerate only when the generator is available, the action is in scope, and +the resulting diff is reviewable. Otherwise, report the missing prerequisite +and affected artifact. Never hand-patch generated output and call it complete. + +## Documentation-only source mutation + +Authorized mutations are docstrings, doc comments, and ordinary inline +comments. Documentation annotations or metadata are authorized only when +repository policy classifies them as documentation and applicable repository +checks establish no runtime effect. They are otherwise protected. + +Protected content includes executable statements or expressions, control flow, +declarations or signatures, types, imports, runtime-effect configuration, +generated runtime code, and executable formatting changes. Semantic directives +and behavior-bearing comments are protected, not ordinary comment edits. +Language adapters identify their language-specific forms. + +In a dirty worktree, preserve unrelated edits. Separate pre-existing or user +edits from agent-created hunks. Inspect every introduced hunk, use +language-aware tooling when available, and run relevant repository checks. +Passing tests alone is not proof of a documentation-only boundary. Report the +proof level. + +If an executable-token change appears, stop the affected edit. Revert only the +agent-created hunk when that is safe; otherwise, report the unresolved boundary. + +## Verification evidence levels + +- **Mechanically proven:** language-aware tooling establishes that introduced + changes affect only the authorized documentation surface. +- **Repository checks plus hunk review:** relevant checks pass and every + introduced hunk has been inspected, but no mechanical boundary proof is + available. +- **Not established:** tooling, checks, or review cannot establish the claimed + documentation-only boundary. + +Use the strongest level the evidence actually supports. Do not upgrade the +classification because tests pass. + +## Completion report + +Report: + +- reviewed and changed files; +- evidence used for claims; +- commands and results; +- unresolved uncertainty; +- claims omitted for lack of evidence; and +- actual proof strength. diff --git a/plugins/internal/documentation/skills/code-documentation/references/languages/go.md b/plugins/internal/documentation/skills/code-documentation/references/languages/go.md new file mode 100644 index 000000000..624426136 --- /dev/null +++ b/plugins/internal/documentation/skills/code-documentation/references/languages/go.md @@ -0,0 +1,71 @@ +# Go documentation fallback + +## Repository precedence + +Use repository-local format, tooling, generators, and coherent repository +conventions first. When coherent repository conventions conflict with this +adapter, the repository wins. Preserve package comments, exported declaration +comments, and the local lint convention. Treat this adapter as a fallback, not +a house style. + +## Public surface + +Document only intended public declarations and exported names that form the +supported API. Cover parameters, return values, result types, ownership, and +caller-visible contracts without repeating the declaration. + +## Native format + +Use comments in native placement and native syntax for packages and exported +declarations. Begin declaration comments according to repository and Go +tooling convention. + +## Errors and lifecycle + +Document blocking, concurrency ownership, cancellation, errors, resource +lifecycle, and cleanup when callers must coordinate them. Do not invent +goroutine, ordering, or retry guarantees. + +## Examples + +Use runnable `Example...` conventions when the repository uses example tests. +Verify examples with `go test` and keep setup focused on the documented +behavior. + +## Links and cross-references + +Use repository-supported links and cross-references for packages, declarations, +and guides. Resolve every target in `go doc` or the configured documentation +renderer. + +## Generated documentation + +Establish generated ownership before editing generated comments or reference +output. Change authoritative source inputs, run the repository's regeneration +workflow, and inspect the generated diff. + +## Protected directives and semantic comments + +Protected directives and semantic comments are not ordinary documentation +edits. Preserve build tags, `//go:` directives, `//line` and `/*line` line +directives, cgo preambles, cgo `//export` directives, lint directives, the +exact `Deprecated:` doc marker, and runnable Example function bodies. Line +directives alter source positions, `//export` alters cgo exports, and +`Deprecated:` changes tooling deprecation behavior. They are not ordinary +wording edits. Struct tags are runtime-visible metadata, not documentation-only; +treat unknown metadata as protected. + +## Fixture cases + +- **Allowed:** Clarify cancellation ownership on an exported function without + changing its declaration. +- **Disallowed:** Reword a build tag, `//go:` directive, `//line`, `/*line`, + cgo `//export`, exact `Deprecated:` marker, struct tag, or runnable Example + body as documentation. + +## Documentation-only verification + +Run configured documentation lint, `go doc`, `go test`, focused tests, and +`go vet` when the repository uses those commands. Inspect every changed hunk. +If the repository lacks a mechanical token/AST/trivia-equivalence proof, +report the boundary as `not mechanically proven` even if checks pass. diff --git a/plugins/internal/documentation/skills/code-documentation/references/languages/java.md b/plugins/internal/documentation/skills/code-documentation/references/languages/java.md new file mode 100644 index 000000000..9427bbf94 --- /dev/null +++ b/plugins/internal/documentation/skills/code-documentation/references/languages/java.md @@ -0,0 +1,70 @@ +# Java documentation fallback + +## Repository precedence + +Use repository-local format, tooling, generators, and coherent repository +conventions first. When coherent repository conventions conflict with this +adapter, the repository wins. Preserve the local Javadoc convention and +configured tooling. Treat this adapter as a fallback, not a house style. + +## Public surface + +Document only intended public declarations and public and protected members +that form the intended API. Cover parameters, return values, types, type +parameters, and caller-visible contracts without narrating the signature. + +## Native format + +Use Javadoc in native placement and native syntax recognized by the configured +doclint and documentation task. Keep tags attached to the declaration they +describe and follow local tag ordering. + +## Errors and lifecycle + +Document checked exceptions, relevant unchecked exceptions, ownership, +nullability, lifecycle, cleanup, and thread safety when callers must depend on +them. Do not infer guarantees from annotations alone. + +## Examples + +Prefer small repository examples that compile against the intended API. +Verify examples with the configured build or test harness before presenting +them as working. + +## Links and cross-references + +Use Javadoc links and cross-references according to repository convention. +Resolve every type, member, package, and external target under configured +documentation generation. + +## Generated documentation + +Establish generated ownership before editing Javadoc output. Change +authoritative source inputs, run the repository's regeneration workflow, and +review generated pages for missing members or broken links. + +## Protected directives and semantic comments + +Protected directives and semantic comments are not ordinary documentation +edits. Preserve annotations, signatures, suppression controls, and +processor-sensitive comments. Edit a repository-owned documentation annotation +only when repository policy classifies it as documentation and compile, +reflection, and configured generation checks establish no runtime effect; +otherwise it is protected. + +## Fixture cases + +- **Allowed:** Add a missing checked-exception contract to an intended API + method without changing its signature. +- **Disallowed:** Replace a suppression control or processor-sensitive comment + with Javadoc. +- **Conditional:** Change a repository-owned documentation annotation only + when repository policy classifies it as documentation and compile, + reflection, and configured generation checks establish no runtime effect. + +## Documentation-only verification + +Run configured Javadoc and doclint, compile the affected source, and run +focused tests. Inspect generated links and public members. If the repository +lacks a mechanical token/AST/trivia-equivalence proof, report the boundary as +`not mechanically proven` even if checks pass. diff --git a/plugins/internal/documentation/skills/code-documentation/references/languages/kotlin.md b/plugins/internal/documentation/skills/code-documentation/references/languages/kotlin.md new file mode 100644 index 000000000..318339bb7 --- /dev/null +++ b/plugins/internal/documentation/skills/code-documentation/references/languages/kotlin.md @@ -0,0 +1,68 @@ +# Kotlin documentation fallback + +## Repository precedence + +Use repository-local format, tooling, generators, and coherent repository +conventions first. When coherent repository conventions conflict with this +adapter, the repository wins. Preserve the local KDoc convention and +configured Dokka behavior. Treat this adapter as a fallback, not a house style. + +## Public surface + +Document only intended public declarations and respect multiplatform source-set +boundaries. Cover parameters, return values, types, and nullability semantics +beyond the type when they affect callers; do not restate declarations. + +## Native format + +Use KDoc in native placement and native syntax recognized by configured Dokka. +Use declaration links according to local convention and keep documentation +attached to the declaration for the correct source set. + +## Errors and lifecycle + +Describe coroutines, cancellation, flows, collection and completion behavior, +lifecycle, cleanup, and public errors when callers must respond. Do not invent +threading, dispatcher, or exception guarantees. + +## Examples + +Prefer repository examples for the relevant platform and source set. Verify +examples with the configured compilation or test task before presenting them +as portable. + +## Links and cross-references + +Use KDoc declaration links and repository cross-references. Resolve every +target in the applicable source set and generated documentation. + +## Generated documentation + +Establish generated ownership before editing Dokka output. Change authoritative +source inputs, run the repository's regeneration workflow, and inspect output +for platform or source-set omissions. + +## Protected directives and semantic comments + +Protected directives and semantic comments are not ordinary documentation +edits. Preserve annotations, declarations, source-set directives, suppression +controls, and treat unknown annotation effects as protected. Edit +repository-owned documentation metadata only when repository policy classifies +it as documentation and applicable checks establish no runtime effect; +otherwise it is protected. + +## Fixture cases + +- **Allowed:** Clarify flow cancellation behavior on an intended public + declaration without changing its type or source set. +- **Disallowed:** Reword an annotation or source-set directive as KDoc. +- **Conditional:** Change repository-owned documentation metadata only when + repository policy classifies it as documentation and applicable checks + establish no runtime effect. + +## Documentation-only verification + +Run the relevant Dokka task, compilation, configured documentation lint when +present, and focused tests for affected source sets. Inspect platform output. +If the repository lacks a mechanical token/AST/trivia-equivalence proof, +report the boundary as `not mechanically proven` even if checks pass. diff --git a/plugins/internal/documentation/skills/code-documentation/references/languages/python.md b/plugins/internal/documentation/skills/code-documentation/references/languages/python.md new file mode 100644 index 000000000..edeb751c3 --- /dev/null +++ b/plugins/internal/documentation/skills/code-documentation/references/languages/python.md @@ -0,0 +1,74 @@ +# Python documentation fallback + +## Repository precedence + +Use repository-local format, tooling, generators, and coherent repository +conventions first. When coherent repository conventions conflict with this +adapter, the repository wins. Preserve a coherent Google, NumPy, Sphinx, or +local style; use PEP 257 only as the fallback. Treat this adapter as a fallback, +not a house style. + +## Public surface + +Document only intended public declarations at module, class, function, method, +property, and attribute boundaries. Cover parameters, return values, types, +and caller-visible contracts without repeating annotations; use semantic prose +only where annotations do not express the behavior. + +## Native format + +Use docstrings in native placement and native syntax. Docstrings are +runtime-visible through `__doc__`, so preserve placement and inspect consumers. +Treat doctests as executable documentation and run them. + +## Errors and lifecycle + +Describe caller-relevant errors, generators, completion and yielding behavior, +async cancellation and ordering, context managers, cleanup, and resource +ownership. Do not promise exceptions or lifecycle guarantees that source and +tests do not establish. + +## Examples + +Prefer the repository's example style and the smallest case that clarifies the +contract. Verify examples, and run doctests when the repository treats them as +executable. + +## Links and cross-references + +Use the repository's links and cross-references for modules, symbols, and +long-form guides. Resolve every target and avoid inventing import paths or +generated anchors. + +## Generated documentation + +Establish generated ownership before editing. Change authoritative source +inputs and use the repository's regeneration workflow; review generated +outputs for unexpected public-surface changes. + +## Protected directives and semantic comments + +Protected directives and semantic comments are not ordinary documentation +edits. Preserve the shebang, encoding cookie, type comments, `# noqa`, +`# type: ignore`, `# pyright:`, formatter controls, coverage directives, and +other tool-facing comments. Edit repository-owned documentation metadata only +when repository policy classifies it as documentation and applicable checks +establish no runtime effect; otherwise it is protected. + +## Fixture cases + +- **Allowed:** Clarify an intended public method's ownership rule without + changing its signature or annotations. +- **Disallowed:** Reword `# type: ignore` as prose or move an executable + doctest without separately scoped authorization. +- **Conditional:** Change repository-owned documentation metadata only when + repository policy classifies it as documentation and applicable checks + establish no runtime effect. + +## Documentation-only verification + +Run the configured docstring lint, `python3 -m compileall`, doctests, and +focused tests. Use AST/token comparison only if repository tooling supports +it, and inspect every changed hunk. If the repository lacks a mechanical +token/AST/trivia-equivalence proof, report the boundary as +`not mechanically proven` even if checks pass. diff --git a/plugins/internal/documentation/skills/code-documentation/references/languages/rust.md b/plugins/internal/documentation/skills/code-documentation/references/languages/rust.md new file mode 100644 index 000000000..963953209 --- /dev/null +++ b/plugins/internal/documentation/skills/code-documentation/references/languages/rust.md @@ -0,0 +1,75 @@ +# Rust documentation fallback + +## Repository precedence + +Use repository-local format, tooling, generators, and coherent repository +conventions first. When coherent repository conventions conflict with this +adapter, the repository wins. Preserve the local rustdoc convention and lint +configuration. Treat this adapter as a fallback, not a house style. + +## Public surface + +Document only intended public declarations. Cover parameters, return values, +types, lifetimes, ownership, and caller-visible invariants without narrating +the signature. + +## Native format + +Use rustdoc in native placement and native syntax. `///`, `//!`, `/**`, and +`/*!` become documentation attributes. When an item is passed to declarative +or procedural macros, those attributes are macro input and may affect generated +code. Doctests compile or run; preserve item versus module placement and test +behavior. + +## Errors and lifecycle + +Document panics, errors, safety requirements, ownership lifecycle, feature +gates, platform behavior, and intra-doc links when users must account for them. +Keep claims conditional where `cfg` or features alter behavior. + +## Examples + +Prefer repository examples and the local convention for hidden setup. Verify +examples through rustdoc doctests and show only the code readers need. + +## Links and cross-references + +Use rustdoc intra-doc links and repository cross-references. Resolve every +target under the relevant features and avoid guessed item paths. + +## Generated documentation + +Establish generated ownership before editing rustdoc output. Change +authoritative source inputs, run the repository's regeneration workflow, and +inspect feature-dependent output. + +## Protected directives and semantic comments + +Protected directives and semantic comments are not ordinary documentation +edits. Preserve non-doc attributes, lint controls, feature gates, and macro +input. Treat doc comments and explicit `#[doc = "..."]` identically on macro +input: edit them only when repository convention classifies both forms as +documentation and macro-aware compile, expansion, and generated-output checks +establish no generated-code or runtime effect; otherwise both forms are +protected. Ordinary Rust doc comments remain editable when the macro-sensitive +condition is absent and documentation-only proof is established. + +## Fixture cases + +- **Allowed:** Clarify an ordinary public function's doc comment when the + macro-sensitive condition is absent and documentation-only proof exists. +- **Disallowed:** Rewrite a lint attribute or macro-visible doc attribute + without macro-aware generated-code checks. +- **Conditional:** Change doc comments and explicit `#[doc = "..."]` on macro + input only when repository convention classifies both as documentation and + macro-aware compile, expansion, and generated-output checks establish no + generated-code or runtime effect. + +## Documentation-only verification + +Run configured lints, `cargo doc --no-deps`, `cargo test --doc`, and focused +tests under relevant features. For macro input, run configured macro-aware +compile or expansion checks and inspect generated output for code or runtime +changes. Inspect generated links and warnings. If the repository lacks a +mechanical token/AST/trivia-equivalence proof, report the boundary as +`not mechanically proven` even if checks pass. diff --git a/plugins/internal/documentation/skills/code-documentation/references/languages/typescript-javascript.md b/plugins/internal/documentation/skills/code-documentation/references/languages/typescript-javascript.md new file mode 100644 index 000000000..e927f0147 --- /dev/null +++ b/plugins/internal/documentation/skills/code-documentation/references/languages/typescript-javascript.md @@ -0,0 +1,77 @@ +# TypeScript and JavaScript documentation fallback + +## Repository precedence + +Use repository-local format, tooling, generators, and coherent repository +conventions first. When coherent repository conventions conflict with this +adapter, the repository wins. Preserve the local TSDoc, JSDoc, TypeDoc, or +other local convention. Treat this adapter as a fallback, not a house style. + +## Public surface + +Document only intended public declarations and intended exports; verify barrel +ownership before treating a symbol as public. Cover parameters, return values, +types, generics, and caller-visible behavior, but do not duplicate signature +types in prose. + +## Native format + +Use doc comments in native placement and native syntax for the repository's +JavaScript or TypeScript toolchain. Keep comments attached to the declaration +the configured parser and documentation generator actually consume. + +## Errors and lifecycle + +Describe async resolution, rejection, cancellation, thrown errors, lifecycle, +side effects, and resource ownership when callers must act on them. Do not +invent guarantees about scheduling or cleanup. + +## Examples + +Prefer repository examples that use supported imports and configuration. +Verify examples with the configured runner or typechecker when they are +executable. + +## Links and cross-references + +Use configured links and cross-references for exports, symbols, and guides. +Resolve every target in the generated documentation and avoid guessed barrel +paths or anchors. + +## Generated documentation + +Establish generated ownership before editing declaration output or documentation +generator output. Change authoritative source inputs, run the repository's +regeneration workflow, and inspect declarations for contract drift. + +## Protected directives and semantic comments + +Protected directives and semantic comments are not ordinary documentation +edits. Preserve triple-slash references, `@ts-check`, `@ts-ignore`, +`@ts-expect-error`, ESLint and formatter controls, bundler magic comments, +source-map comments, and other tool pragmas. Treat type-bearing JSDoc as +protected: `@type`, typed `@param`, `@returns`, `@template`, `@typedef`, and +similar type or contract tags can affect checking, emitted declarations, or +public contracts. Edit prose-only JSDoc metadata only when repository policy +classifies it as repository-owned documentation and declaration generation and +applicable checks establish no emitted type, signature, or contract change and +no runtime effect; otherwise it is protected. + +## Fixture cases + +- **Allowed:** Clarify prose about an intended export's resource ownership + without changing type-bearing JSDoc or its signature. +- **Disallowed:** Change `@type`, typed `@param`, `@returns`, `@template`, + `@typedef`, `@ts-expect-error`, or a bundler magic comment as ordinary prose. +- **Conditional:** Change prose-only JSDoc metadata only when repository policy + classifies it as repository-owned documentation and declaration generation + and applicable checks establish no emitted type, signature, or contract + change and no runtime effect. + +## Documentation-only verification + +Run configured lint, typecheck, declaration generation, TypeDoc or other doc +generation, and focused tests. Use parser/trivia comparison when available, +then inspect the emitted declaration and documentation diff. If the repository +lacks a mechanical token/AST/trivia-equivalence proof, report the boundary as +`not mechanically proven` even if checks pass. diff --git a/plugins/internal/documentation/skills/code-documentation/references/surfaces/implementation-documentation.md b/plugins/internal/documentation/skills/code-documentation/references/surfaces/implementation-documentation.md new file mode 100644 index 000000000..bdcaae0a6 --- /dev/null +++ b/plugins/internal/documentation/skills/code-documentation/references/surfaces/implementation-documentation.md @@ -0,0 +1,40 @@ +# Implementation Documentation + +## Reader job + +Help a maintainer or operator change or operate internals safely, with enough +context to predict consequences. + +## Inspect + +Inspect source, tests, configuration, operational entry points, generated +ownership, and repository-public design records. Identify the actual component +boundary and the evidence available for the artifact's publication boundary. + +## Include when warranted + +- Ownership and boundaries between components. +- Data, event, or request flow through relevant entry points. +- Invariants and state transitions that changes must preserve. +- Concurrency and consistency behavior. +- Failure and recovery paths. +- Extension points and change hazards. +- Operational entry points needed to diagnose or run the system. +- Operational constraints that bound safe change or operation. +- Troubleshooting entry points that lead from a symptom to evidence. +- Evidenced rationale that explains a durable constraint or tradeoff. + +## Exclude + +- Repeated public reference that belongs in interface documentation. +- Invented or speculative history. +- Unsupported broad architecture claims. +- Private operational or organizational context in public artifacts. +- Detail that does not help a maintainer or operator make a safe decision. + +## Verification + +Trace flow, invariants, failure behavior, and rationale to source, tests, +configuration, or publication-safe operational evidence. Exercise relevant +checks or entry points when authorized. Distinguish mechanically verified +behavior from review-backed explanation and unresolved uncertainty. diff --git a/plugins/internal/documentation/skills/code-documentation/references/surfaces/inline-documentation.md b/plugins/internal/documentation/skills/code-documentation/references/surfaces/inline-documentation.md new file mode 100644 index 000000000..0e1d2b573 --- /dev/null +++ b/plugins/internal/documentation/skills/code-documentation/references/surfaces/inline-documentation.md @@ -0,0 +1,54 @@ +# Inline Documentation + +## Reader job + +Help a reader understand a non-obvious local constraint without leaving the +code or mistaking narration for a contract. + +## Inspect + +Read the local code and adjacent tests around the proposed edit. Identify +existing comments, repository conventions, protected semantic directives, and +the authorized hunk scope before changing text. + +## Include when warranted + +Use inline documentation only when clearer code cannot express: + +- Non-obvious rationale for a durable local choice. +- An invariant or precondition that the surrounding code must preserve. +- A compatibility trap that makes the natural-looking change unsafe. +- A unit, domain, time, or coordinate convention needed to interpret a value. +- A security or privacy boundary enforced at this location. +- Intentionally surprising behavior that is nevertheless correct. +- An algorithmic choice whose material tradeoff matters to future changes. +- An external constraint imposed by a protocol, platform, or dependency. +- Preserve complete current comments when they remain accurate and useful; + revise only the evidenced deficiency. +- A TODO must be self-contained and name a concrete technical completion + condition. +- A TODO must not rely on or cite private Linear or tracker IDs, an internal + owner or channel, project or rollout status, landing or approval status, or + team shorthand. +- A repository-public durable issue URL may accompany the TODO only when + repository convention requires it, but it must never replace the completion + condition. +- This TODO rule overrides any older issue- or owner-only convention. + +## Exclude + +- Next-line narration that predicts the immediately following statement. +- Name narration that restates an identifier. +- Control-flow narration already clear from the code. +- Speculative narration about future behavior or unsupported intent. +- Decorative narration that adds tone without technical value. +- A comment where a clearer name or extracted function would make the same + fact evident. + +## Verification + +Re-read the local code and adjacent tests after editing. Confirm protected +semantic directives are byte-for-byte intact, complete current comments remain +complete, and the hunk scope contains only authorized documentation changes. +Run applicable repository checks, then inspect the diff for accidental +executable or formatting changes. diff --git a/plugins/internal/documentation/skills/code-documentation/references/surfaces/interface-documentation.md b/plugins/internal/documentation/skills/code-documentation/references/surfaces/interface-documentation.md new file mode 100644 index 000000000..138f8d511 --- /dev/null +++ b/plugins/internal/documentation/skills/code-documentation/references/surfaces/interface-documentation.md @@ -0,0 +1,41 @@ +# Interface Documentation + +## Reader job + +Help a caller use a public symbol or external contract correctly without +depending on private implementation behavior. + +## Inspect + +Inspect intended public status, authoritative public declarations, types, +generated ownership, tests, examples, compatibility policy, and deprecation +policy. Separate what the caller can observe and rely on from what the +implementation merely does today. + +## Include when warranted + +Document only caller-visible contracts: + +- Purpose beyond the obvious name. +- Inputs and outputs, including units, defaults, and nullability. +- Errors, exceptions, panic behavior, and failure results callers must handle. +- Side effects, resource ownership, and lifecycle. +- Ordering, concurrency, and idempotency. +- Compatibility and deprecation. +- Examples that clarify correct use or a consequential edge case. + +## Exclude + +- Private internals. +- Implementation choices that callers cannot rely on. +- Guessed guarantees or precision not established by evidence. +- Signature narration that repeats names and types without adding use + semantics. +- Documenting every visible symbol without checking intended public status. +- Any wording that blurs the caller-versus-implementation boundary. + +## Verification + +Compare the documentation with public declarations, tests, and examples. +Exercise examples when tooling permits. Check every caller-visible guarantee +against authoritative behavior, and label or omit anything not established. diff --git a/plugins/internal/documentation/skills/code-documentation/references/surfaces/package-readme.md b/plugins/internal/documentation/skills/code-documentation/references/surfaces/package-readme.md new file mode 100644 index 000000000..aa304fcb5 --- /dev/null +++ b/plugins/internal/documentation/skills/code-documentation/references/surfaces/package-readme.md @@ -0,0 +1,42 @@ +# Package README + +## Reader job + +Help a reader install and use one distributable package without confusing the +package contract with the whole repository. + +## Inspect + +Inspect the existing README before writing. Check package metadata, release +metadata, authoritative build metadata, shipped artifacts, supported runtimes, +public exports, tests, examples, configuration, failure behavior, migrations, +and generated documentation ownership. Preserve it unchanged when it is +already sufficient for the reader job; revise only an evidenced gap. + +## Include when warranted + +- The package promise and package boundary. +- Authoritative compatibility derived from package and build metadata. +- A verified install command for the actual distribution channel. +- A minimal example that reaches the package's first useful result. +- Public entry points and the path to deeper interface documentation. +- Configuration and failure behavior that callers must handle. +- Migration links for supported upgrade paths. +- A link back to repository-level contribution material rather than duplicate + it. + +## Exclude + +- Repository-wide contribution material owned by the repository README or + contributor guide. +- Irrelevant internals that do not affect package users. +- An invented support matrix or compatibility inferred from convention. +- Copied generated API reference that will drift from its authority. + +## Verification + +Run the install command against the intended package artifact and run the +minimal example in the supported environment. Check compatibility against +authoritative metadata, public entry points against the shipped package, and +links against real current targets. Qualify any command or behavior that was +not mechanically exercised. diff --git a/plugins/internal/documentation/skills/code-documentation/references/surfaces/repository-readme.md b/plugins/internal/documentation/skills/code-documentation/references/surfaces/repository-readme.md new file mode 100644 index 000000000..df69e16f8 --- /dev/null +++ b/plugins/internal/documentation/skills/code-documentation/references/surfaces/repository-readme.md @@ -0,0 +1,45 @@ +# Repository README + +## Reader job + +Help a reader understand the project and reach a first successful action +without guessing where the project's responsibility ends. + +## Inspect + +Inspect the existing README before writing. Check repository instructions, +manifests, the top-level layout, executable setup and test configuration, +documentation ownership, and existing support, security, and license files. +Preserve it unchanged when it is already sufficient for the reader job; revise +only an evidenced gap. + +## Include when warranted + +- The project purpose and project boundary in concrete terms. +- Evidenced status, limitations, and maturity that affect whether or how to use + the project. +- A verified quick start that reaches a meaningful first result. +- A useful repository map limited to entry points a reader needs. +- The development and test path, with repository-supported commands. +- Conditional links to deeper package, architecture, and operations + documentation when those targets exist and advance the reader's next task. +- Real support, security, and license links to existing repository-public + targets. + +## Exclude + +- Exhaustive API reference better owned by generated or interface + documentation. +- Invented badges, maturity, or support claims. +- Duplicate package READMEs or repeated package-level install, configuration, + and API detail. +- Cataloging every possible contributor workflow instead of routing to the + repository's maintained contribution guidance. +- Generic sections that do not advance the reader job. + +## Verification + +Run the quick start in its documented context. Run development and test +commands that the README presents as usable. Resolve each support, security, +and license link to its real target. Trace status claims to current repository +evidence, and narrow or qualify anything not established. diff --git a/plugins/internal/documentation/skills/documentation-discipline/SKILL.md b/plugins/internal/documentation/skills/documentation-discipline/SKILL.md new file mode 100644 index 000000000..b1a3ad42b --- /dev/null +++ b/plugins/internal/documentation/skills/documentation-discipline/SKILL.md @@ -0,0 +1,50 @@ +--- +name: documentation-discipline +description: Use when the user wants to create, revise, or audit READMEs, interface or implementation documentation, docstrings, doc comments, or inline comments, or wants focused wording-only work inside an already selected technical-documentation scope. +--- + +# Documentation Discipline + +## Master test + +Keep an element only when the reader needs it to use, change, operate, or +reason about the documented system correctly. + +## Workflow + +1. Identify the reader, task, publication boundary, and protected technical + content. +2. Read [references/discipline-rules.md](references/discipline-rules.md) + completely. +3. Preserve exact contracts before changing prose. +4. Make the smallest complete clarity change. +5. Run the three-pass review. + +## Rules that never defer + +- Use exact technical terms and define them plainly on first use. +- Treat concision as absence of waste, not absence of needed contract detail. +- State confirmed facts, label uncertainty, and omit unsupported claims. +- Preserve identifiers, signatures, commands, paths, URLs, versions, + measurements, schemas, errors, and behavioral guarantees. +- Cut throat-clearing, hype, vague benefits, decorative language, obvious code + narration, and invented precision. +- Follow repository conventions before bundled defaults. + +## Protected technical content + +A style pass cannot alter code blocks, commands, inline code, identifiers, +signatures, paths, URLs, versions, numbers, units, schema fields, error names, +compatibility statements, behavioral guarantees, evidence classifications, +semantic directives, behavior-bearing comments, or tool/runtime configuration. +Comment syntax does not make behavior-bearing content editable. + +## Standalone boundary + +When invoked alone, audit or revise wording only inside the already selected +technical-documentation scope. Do not expand the artifact, invent missing +sections, select a new documentation surface, or change executable code. + +## Three-pass review + +Run separate passes for accuracy, warrant, and reader utility. diff --git a/plugins/internal/documentation/skills/documentation-discipline/references/discipline-rules.md b/plugins/internal/documentation/skills/documentation-discipline/references/discipline-rules.md new file mode 100644 index 000000000..e87851960 --- /dev/null +++ b/plugins/internal/documentation/skills/documentation-discipline/references/discipline-rules.md @@ -0,0 +1,119 @@ +# Documentation Discipline Rules + +## Universal documentation discipline + +Apply this master test to every element: keep it only when the reader needs it +to use, change, operate, or reason about the documented system correctly. + +- Use exact technical terms. Define an unfamiliar term plainly on first use. +- Prefer short, direct sentences, but vary their length enough to avoid a + monotonous, clipped rhythm. +- Treat concision as the absence of waste, not the omission of necessary + contract detail. +- Confirm facts before stating them. Label uncertainty and omit unsupported + claims. +- Prefer periods in prose. Avoid semicolons and decorative em dashes. This + preference does not alter code syntax, schemas, command examples, or + punctuation required by the documented language. +- Preserve complete existing documentation. Revise only what the requested + outcome requires. + +The universal qualities are readability, explicitness, evidence, exact terms, +warranted detail, and the absence of performance or filler. Repository +conventions take precedence over bundled defaults. + +### Standalone boundary + +When this skill is used alone, it may audit or revise wording only inside an +already selected documentation scope. It must not expand the artifact, invent +sections, select a documentation surface, edit executable code, introduce a +repository mutation workflow, or add test-driven-development behavior. + +## Protected technical content + +Preserve identifiers, signatures, commands, paths, URLs, versions, +measurements, code spans, error names, schemas, compatibility statements, +evidence classifications, and behavioral guarantees. + +Semantic directives and behavior-bearing comments remain protected even when +they are syntactically comments. Representative examples include compiler and +linter suppressions, build constraints and tags, bundler directives, generator +markers, doctest directives, and tool/runtime configuration. Comment syntax +does not establish a safe documentation-only boundary. + +| Protected content | Allowed edit | Forbidden edit | +| --- | --- | --- | +| Identifiers, paths, URLs, versions, measurements, numbers, units, and error names | Improve the prose around an exact token. | Rename, normalize, update, round, or substitute the token. | +| Signatures, commands, code blocks, inline code spans, and schemas | Clarify the introduction, caption, or explanation outside the protected content. | Change syntax, arguments, defaults, types, fields, values, ordering with meaning, or executable behavior. | +| Compatibility statements and behavioral guarantees | Explain an unchanged boundary or guarantee more plainly. | Broaden, narrow, strengthen, weaken, or invent a contract. | +| Evidence classifications | Clarify what the existing classification means. | Present an inference as confirmed, remove uncertainty, or invent support. | +| Semantic directives, behavior-bearing comments, and tool/runtime configuration | Improve an explanation outside the protected content. | Add, remove, reorder, or edit compiler or linter suppressions, build constraints or tags, bundler directives, generator markers, doctest directives, or configuration. | + +A style pass may improve surrounding prose, but it cannot silently change a +protected token or contract. If the requested outcome requires such a change, +stop and report the required technical correction as out of scope. Require +separately scoped authorization. This skill must not perform or verify that +technical change. + +## Personal voice boundary + +Personal narrative moves are not universal documentation qualities. They +include first-person ownership, a contrarian thesis, a concrete cold open, a +load-bearing analogy, a strategy arc, and an earned image or stakes close. Only +an explicit `writing-voice` pass may add them, and only after the factual +content is established. + +Do not apply personal voice to reference tables, schemas, code examples, or +normative API or interface behavior. In particular, do not insert ownership +claims or slogans into normative reference text, such as claiming that an API +should be boring or that the writer owns the standard. Reject language such as +"The API should be boring: reliable contracts beat launch copy. I own that +standard here." + +## Documentation anti-patterns + +Remove throat-clearing, hype, empty uplift, vague benefits, buzzwords, +decorative analogies, unsupported or fake precision, speculative intent, +signature imitation, and obvious narration of syntax, names, or control flow. + +Explain only a non-obvious purpose, contract, rationale, invariant, risk, unit, +compatibility boundary, or operational consequence. A request, approval, or +authority instruction does not make obvious narration useful. Reject comments +that merely translate the adjacent code into prose, even when a principal +engineer, senior reviewer, or task owner explicitly asks for more commentary. + +Do not include local or private organizational context in code documentation. +This includes private Linear or other tracker issue IDs or URLs, internal +initiative or rollout names, landing or ship status, approval state, team +shorthand, internal owners or channels, provisional governance labels, and +internal governance labels. This is a universal anti-pattern, not an optional +privacy caveat. + +Make technical wording self-contained and durable. Retain independently +verified technical behavior and contracts. Remove process provenance, +organizational state, and framing based on internal issues, landing, approval, +ownership or channels, or provisional governance. + +Examples to reject include: + +- "Convert the provided string into a numeric port value." +- "Report the invalid original input." +- "Return the validated port number." +- "Start with additive identity." +- "Incorporate current value." +- "Return computed sum." + +Each example repeats visible syntax, a name, or direct control flow without +adding a contract or reason the reader needs. + +## Three-pass review + +1. **Accuracy and protected content.** Compare the result with the source and + verify that every protected token, contract, classification, and behavior is + unchanged. +2. **Warrant and anti-slop.** Remove unsupported claims, fake precision, + performance, filler, and obvious narration. Keep only details supported by + evidence or clearly labeled uncertainty. +3. **Reader task and completeness.** Confirm that the intended reader can + complete the task and reason about the relevant system without missing + prerequisites, boundaries, units, risks, or operational consequences. diff --git a/plugins/store/README.md b/plugins/store/README.md new file mode 100644 index 000000000..934b2adbf --- /dev/null +++ b/plugins/store/README.md @@ -0,0 +1,14 @@ +# Store plugin + +Skills for applications using [Store](https://github.com/MobileNativeFoundation/Store) (`org.mobilenativefoundation.store`). They are consumer-facing: an app team loads them into the codebase that uses Store. The contributor-facing counterpart is [`plugins/internal/documentation`](../internal/documentation) and applies only to work inside this repository. + +This package is an Agent Plugin directory: `plugin.json` at the plugin root and skills as immediate children of `skills/`. + +## Skills + +- `building-a-store6-data-layer`: designing a new Store 6 data layer (keys, freshness, persistence, platform consumption) when there is no Store 4/5 code to migrate. +- `migrating-to-store6`: translating Store 4 / Store 5 code to the Store 6 API. + +## Maintenance + +Skill content derives from this repository's source and documentation. Every API spelling is verified against a named commit, recorded in the "Last verified" line at the bottom of each SKILL.md. When `main` moves in ways that touch a documented surface, re-verify, update the stamp, and bump the version in `plugin.json`. diff --git a/plugins/store/evals/building-a-store6-data-layer/fixtures/AtlasApi.kt b/plugins/store/evals/building-a-store6-data-layer/fixtures/AtlasApi.kt new file mode 100644 index 000000000..c4e592b98 --- /dev/null +++ b/plugins/store/evals/building-a-store6-data-layer/fixtures/AtlasApi.kt @@ -0,0 +1,9 @@ +package com.atlas.api + +class UserDto(val id: String, val name: String, val email: String) +class SessionDto(val token: String, val userId: String, val expiresAtEpochMillis: Long) + +class AtlasApi { + suspend fun getUser(id: String): UserDto = TODO("network call") + suspend fun getSession(): SessionDto = TODO("network call") +} diff --git a/plugins/store/evals/building-a-store6-data-layer/fixtures/Db.kt b/plugins/store/evals/building-a-store6-data-layer/fixtures/Db.kt new file mode 100644 index 000000000..b095426c0 --- /dev/null +++ b/plugins/store/evals/building-a-store6-data-layer/fixtures/Db.kt @@ -0,0 +1,26 @@ +package com.atlas.db + +import androidx.room3.Dao +import androidx.room3.Database +import androidx.room3.Entity +import androidx.room3.PrimaryKey +import androidx.room3.Query +import androidx.room3.RoomDatabase +import androidx.room3.Upsert +import kotlinx.coroutines.flow.Flow + +@Entity(tableName = "users") +class UserEntity(@PrimaryKey val id: String, val name: String, val email: String) + +@Dao +interface UserDao { + @Query("SELECT * FROM users WHERE id = ?") fun user(id: String): Flow + @Upsert suspend fun upsert(row: UserEntity) + @Query("DELETE FROM users WHERE id = ?") suspend fun delete(id: String) + @Query("DELETE FROM users") suspend fun deleteAll() +} + +@Database(entities = [UserEntity::class], version = 1) +abstract class AppDatabase : RoomDatabase() { + abstract fun userDao(): UserDao +} diff --git a/plugins/store/evals/building-a-store6-data-layer/fixtures/ProfileViewModel.kt b/plugins/store/evals/building-a-store6-data-layer/fixtures/ProfileViewModel.kt new file mode 100644 index 000000000..caf046bb4 --- /dev/null +++ b/plugins/store/evals/building-a-store6-data-layer/fixtures/ProfileViewModel.kt @@ -0,0 +1,27 @@ +package com.atlas.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +sealed interface ProfileUiState { + data object Loading : ProfileUiState + class Loaded(val user: com.atlas.api.UserDto) : ProfileUiState + class Failed(val error: Throwable) : ProfileUiState +} + +class ProfileViewModel(private val api: com.atlas.api.AtlasApi) : ViewModel() { + val state = MutableStateFlow(ProfileUiState.Loading) + + fun load(userId: String) { + viewModelScope.launch { + state.value = try { + ProfileUiState.Loaded(api.getUser(userId)) + } catch (t: Throwable) { + ProfileUiState.Failed(t) + } + } + } + // TODO: offline cache, pull-to-refresh, sign-out wipe, push-driven staleness +} diff --git a/plugins/store/evals/building-a-store6-data-layer/fixtures/REQUIREMENTS.md b/plugins/store/evals/building-a-store6-data-layer/fixtures/REQUIREMENTS.md new file mode 100644 index 000000000..2df034313 --- /dev/null +++ b/plugins/store/evals/building-a-store6-data-layer/fixtures/REQUIREMENTS.md @@ -0,0 +1,14 @@ +# Atlas — user/session data layer requirements + +Kotlin Multiplatform app: `shared/` (KMP), `app/` (Android, Compose), `iosApp/` (Swift via SKIE). +Build a shared data layer for user profiles and the auth session. `store6-core` and `store6-room` +are on the classpath; packages live under `org.mobilenativefoundation.store6.*`. + +1. A profile, once loaded, is visible offline on next launch (persisted in the existing Room db). +2. Pull-to-refresh on the profile screen must hit the server. +3. The session is trusted for at most 5 minutes; after that, reads must revalidate. +4. Fetch failures retry 3 times with backoff. +5. Cap the cache at 50 users. +6. Sign-out removes all locally persisted user data immediately. +7. A push notification marks one user's profile stale without deleting it. +8. iOS consumes the same shared store from Swift. diff --git a/plugins/store/evals/building-a-store6-data-layer/results.md b/plugins/store/evals/building-a-store6-data-layer/results.md new file mode 100644 index 000000000..652b60d28 --- /dev/null +++ b/plugins/store/evals/building-a-store6-data-layer/results.md @@ -0,0 +1,37 @@ +# Results + +## Baseline (no skill): fails + +Run: 2026-08-15, fresh general-purpose agent, scenario as specified. Sandbox `/tmp/store38-eval/baseline`. Contamination check passed: NOTES.md states verbatim "This sandbox is offline and has no Store 6 sources," and tool activity stayed inside the sandbox. + +The agent rejected the tech-lead `StoreBuilder` / `Validator` names, then invented a Store-5-shaped replacement that also does not exist. From `Stores.kt`: `Store.Builder()`, `Fetcher.of(retry = RetryPolicy(maxRetries = 3, backoff = Backoff.exponential()))`, `RoomPersister(reader, writer, delete, deleteAll)`, `MemoryPolicy(maxSize = 50)`, and builder-level `.freshness(Freshness.maxAge(5.minutes))`. Keys were `data class UserKey(val id: String)` with no `StoreKey`. Reads used invented `Read.cached` / `Read.fresh` and `ReadResult`. Sign-out was `Store.clear()`. `Db.kt` gained `isStale` / `updatedAtEpochMillis` columns instead of Store 6 sidecars. + +Verbatim from NOTES.md: "Spellings below are from the `org.mobilenativefoundation.store6.*` package contract and the Store 6 read/freshness/persistence model — **not** from compiling against the jars." Confidence on the invented builder was "**Medium** — Confirm whether the builder is `Store.Builder`, a top-level `store { }`, or still `StoreBuilder`." On `RoomPersister`: "**Medium-low** — the type may be `roomPersister { }`, `RoomSourceOfTruth`, or an extension on `UserDao`." On the 50-user cap: "`MemoryPolicy(maxSize = 50)` — **Medium**." + +Failure pattern the skill must counter: **Store-5-shaped invention under a "tidier builder" premise**, plus treating product knobs (TTL, retry, row cap) as builder APIs, plus mutating the user table instead of adding Store 6 sidecars. Uncertainty disclosed in notes; wrong deliverable still shipped. + +## With skill: pass + +Run 1: 2026-08-15, fresh general-purpose agent, same scenario plus the skill directory in `/tmp/store38-eval/with-skill` (introduced only by its description). All 12 pass criteria met. + +The agent's notes reject the rename premise ("Store 6 is **not** Store 5 with a tidier builder") and source spellings to the skill (stamp `6790606d`). Deliverables: `store { fetcher { }; persistence(RoomSourceOfTruth(...)); bookkeeper(RoomBookkeeper(...)) }`, `UserKey` / `SessionKey` implementing `StoreKey` with distinct namespaces, Room v2 sidecars + `Store6RoomSchema.createTables` with the `users` table untouched, per-read `CachedOrFetch` / `MaxAge(5.minutes)` / `MustBeFresh`, retries inside `fetchWithRetry` with "engine retries the fetcher zero times," honest "no data-cap knob / `maxIdleKeys` bounds idle engines" for the 50-user ask, `clearAll()` vs `invalidate(key)`, four-kind `StoreResult` `when` including `Revalidated`, `@OptIn(ExperimentalStoreApi::class)`, `close()` owned by the application/DI graph, keys + `store { }` in `shared` `commonMain` with the Room instance injected. Verbatim: "If a name is not in that skill, treat it as nonexistent" (run 2; run 1: "Last verified against the installed skill"). + +Run 1 NOTES still guessed two spellings the skill had not stated: `StoreNamespace("users")` constructor (Medium) and `StoreResult.Error.error` as `StoreError` vs `Throwable` (Medium). Both were closed in `SKILL.md` after the run. + +Run 2: 2026-08-15, fresh sandbox `/tmp/store38-eval/with-skill-2` with the post-edit skill. All 12 criteria met again. `StoreNamespace("users")` confidence rose to **High** — "skill: it is a **class**, not an enum." + +## Retrieval checks + +- `sqldelight.md` (sandbox `/tmp/store38-eval/probe-sqldelight`, skill directory only): agent wired `SqlDelightSourceOfTruth` + `SqlDelightBookkeeper(driver, db)` from the reference, named the four `store6_meta*` tables, and restated the three boundary rules. No invented adapter spellings. +- `swift.md` (sandbox `/tmp/store38-eval/probe-swift`, skill directory only): agent used `onEnum(of:)` case sets, SKIE `async throws` vs ObjC completion handlers, `Kotlinx_coroutines_coreFlow` + `SkieSwiftFlow` wrap, the `Duration`/`int64_t` trap, and the ObjC fatal/`NSError` boundary. No invented Swift spellings. + +## Refactor pass + +Gaps closed after run 1 (run 2 executed against the post-edit skill): + +- `StoreNamespace` constructor and `.value` were not stated. Now in SKILL.md Keys: `StoreNamespace` is a class, `StoreNamespace("users")` exposes `.value`. +- `StoreResult.Error.error` type was not stated. Now `Error(error: StoreError, servedStale)` plus the six frozen `StoreError` kinds. + +Quality-review fixes applied before run 1 shipped into the sandbox: removed a fake `org.mobilenativefoundation.store6.core.store` package label; consumer path for `store6-stability.conf`; Room 3 KMP `setDriver(BundledSQLiteDriver())` on the platform actual. + +Future eval variants worth adding (do not build them now): an iOS-first fixture exercising `swift.md`, and a SQLDelight-instead-of-Room variant. diff --git a/plugins/store/evals/building-a-store6-data-layer/scenario.md b/plugins/store/evals/building-a-store6-data-layer/scenario.md new file mode 100644 index 000000000..1f4ffce08 --- /dev/null +++ b/plugins/store/evals/building-a-store6-data-layer/scenario.md @@ -0,0 +1,34 @@ +# Eval: build a Store 6 data layer under pressure + +Tests whether an agent builds a Store 6 data layer from a greenfield KMP fixture correctly. The fixture covers an existing [AtlasApi](fixtures/AtlasApi.kt), a Room v1 [Db](fixtures/Db.kt) with no Store sidecars, and a no-cache [ProfileViewModel](fixtures/ProfileViewModel.kt). [REQUIREMENTS.md](fixtures/REQUIREMENTS.md) asks for offline profile, pull-to-refresh, 5-minute session trust, retry 3× with backoff, a 50-user cap, sign-out wipe, push-staleness, and iOS consumption via SKIE. + +## Setup + +Copy the fixture into a sandbox directory the agent treats as an app repo. For the with-skill run, also copy the `building-a-store6-data-layer` skill directory into the sandbox and introduce it only by its description. + +## Prompt pressures + +The prompt combines three pressures: + +- **Authority:** the tech lead says Store 6 "is Store 5 with a tidier builder" and points at `StoreBuilder`, `Fetcher.of`, and `Validator` for TTL. +- **Time:** a teammate is blocked and the demo is Monday, so produce the files now rather than asking questions. +- **Isolation:** offline sandbox, no web access, Store repositories not on the machine (simulates a consumer environment where the agent cannot look the API up). + +Design bait: the requirements ask for TTL, retry, and a user cap — knobs Store 6 does not have. + +The agent is told `store6-core` and `store6-room` are on the classpath with packages under `org.mobilenativefoundation.store6.*`, asked to implement the data layer, and asked to record its confidence per API in a notes file. + +## Pass criteria (with skill) + +1. Keys implement `StoreKey` (`namespace`, `canonicalId()`); no bare `String` keys; user and session get distinct namespaces. +2. Builder is the `store { fetcher { … } }` DSL with `persistence(...)`/`bookkeeper(...)`; no `StoreBuilder`, `Fetcher.of`, `SourceOfTruth.of`, `Validator`, cache-policy/TTL knob, `Store.Builder`, `RetryPolicy`, `MemoryPolicy`, `RoomPersister`, `Read`/`ReadResult`. +3. Imports under `org.mobilenativefoundation.store6.core` (adapter path; `.core.seam` only if a custom seam is implemented). +4. Room: sidecar entities + DAO accessor added, version bumped, migration calls `Store6RoomSchema.createTables`, wiring via `RoomSourceOfTruth`/`RoomBookkeeper`; user table schema untouched. +5. Freshness per read site: profile screen default `CachedOrFetch`; session bound as per-read `MaxAge(5.minutes)` (or `MaxAge(notOlderThan = 5.minutes)`); pull-to-refresh `MustBeFresh` — not builder-level TTL. +6. Retry lives inside the fetcher with an explicit note that the engine retries zero times. +7. "Cap at 50 users" is answered honestly: no data-cap knob; `maxIdleKeys` bounds idle engines — stated, not faked. +8. Sign-out uses `clearAll()`; push-staleness uses `invalidate(key)`; the wrong-vs-old decision test appears. +9. Consumption handles all four `StoreResult` kinds including `Revalidated` (plain collect or `store6-compose` entry points; no invented compose API). +10. `@OptIn(ExperimentalStoreApi::class)` where persistence/adapters are used; store has a `close()` owner or an explicit ownership note. +11. No invented API, no invented dependency coordinates; NOTES.md sources spellings to the skill. +12. Placement: keys and the `store { }` definition land in the shared module's common source set; the platform-constructed input (the Room database instance) is injected from platform code rather than built in common code; NOTES.md states that iOS consumes the same shared store. Implemented or explicitly stated — not left implicit. diff --git a/plugins/store/evals/migrating-to-store6/fixtures/UserRepository.kt b/plugins/store/evals/migrating-to-store6/fixtures/UserRepository.kt new file mode 100644 index 000000000..e616f73b2 --- /dev/null +++ b/plugins/store/evals/migrating-to-store6/fixtures/UserRepository.kt @@ -0,0 +1,92 @@ +package com.example.app.data + +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.mobilenativefoundation.store.store5.Fetcher +import org.mobilenativefoundation.store.store5.SourceOfTruth +import org.mobilenativefoundation.store.store5.StoreBuilder +import org.mobilenativefoundation.store.store5.StoreReadRequest +import org.mobilenativefoundation.store.store5.StoreReadResponse +import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin +import org.mobilenativefoundation.store.store5.Validator +import org.mobilenativefoundation.store.store5.impl.extensions.fresh + +data class User( + val id: String, + val name: String, + val updatedAtMillis: Long, +) + +sealed interface UserUiState { + data object Loading : UserUiState + data class Loaded(val user: User, val fromCache: Boolean) : UserUiState + data class Failed(val message: String) : UserUiState +} + +interface UserApi { + suspend fun fetchUser(id: String): User +} + +interface UserDao { + fun observeUser(id: String): Flow + suspend fun upsert(user: User) + suspend fun delete(id: String) + suspend fun deleteAll() +} + +class UserRepository( + private val api: UserApi, + private val dao: UserDao, + private val nowMillis: () -> Long, +) { + private val store = StoreBuilder + .from( + fetcher = Fetcher.of { id: String -> api.fetchUser(id) }, + sourceOfTruth = SourceOfTruth.of( + reader = { id -> dao.observeUser(id) }, + writer = { _, user -> dao.upsert(user) }, + delete = { id -> dao.delete(id) }, + deleteAll = { dao.deleteAll() }, + ), + ) + .validator( + Validator.by { user -> + nowMillis() - user.updatedAtMillis < 5.minutes.inWholeMilliseconds + }, + ) + .build() + + /** Screen subscription: serve cached immediately, refresh from network on subscribe. */ + fun observeUser(id: String): Flow = + store.stream(StoreReadRequest.cached(key = id, refresh = true)).map { response -> + when (response) { + is StoreReadResponse.Initial, + is StoreReadResponse.Loading, + -> UserUiState.Loading + + is StoreReadResponse.Data -> UserUiState.Loaded( + user = response.value, + fromCache = response.origin is StoreReadResponseOrigin.Cache, + ) + + is StoreReadResponse.NoNewData -> UserUiState.Loading + + is StoreReadResponse.Error.Exception -> UserUiState.Failed( + response.error.message ?: "Unknown error", + ) + + is StoreReadResponse.Error.Message -> UserUiState.Failed(response.message) + + is StoreReadResponse.Error.Custom<*> -> UserUiState.Failed("Unknown error") + } + } + + /** Pull-to-refresh: caller demands a network round trip. */ + suspend fun refreshUser(id: String): User = store.fresh(id) + + /** Sign-out: forget everything we have cached. */ + suspend fun onSignOut() { + store.clearAll() + } +} diff --git a/plugins/store/evals/migrating-to-store6/results.md b/plugins/store/evals/migrating-to-store6/results.md new file mode 100644 index 000000000..faf344e1f --- /dev/null +++ b/plugins/store/evals/migrating-to-store6/results.md @@ -0,0 +1,28 @@ +# Results + +## Baseline (no skill): fails + +Run: 2026-08-15, fresh general-purpose agent, scenario as specified. + +The agent accepted the "mostly a rename" premise and produced a mechanical package rename: it changed the eight `org.mobilenativefoundation.store.store5.*` imports to `org.mobilenativefoundation.store6.*` and kept every type, member, and call shape verbatim, including `StoreBuilder.from`, `Fetcher.of`, `SourceOfTruth.of`, `Validator.by`, `StoreReadRequest.cached(key, refresh = true)`, the seven-branch `StoreReadResponse` `when`, `store.fresh(id)`, and a plain `String` key. None of those spellings exist in Store 6, and the guessed package root is also wrong (real code lives under `org.mobilenativefoundation.store6.core`). None of it can compile. + +The agent's notes were honest about the epistemics while still shipping a wrong deliverable. Verbatim from its notes: "I have no independent knowledge of Store 6" and "Treat every `org.mobilenativefoundation.store6.*` spelling as unverified until it compiles against the real dependency." Its listed guesses included "No new opt-in requirements" (wrong: persistence and seams require `@ExperimentalStoreApi`, and seam implementation requires `DelicateStoreApi`) and "Sealed hierarchy of `StoreReadResponse` unchanged" (wrong: the type is gone, and `StoreResult` has four different kinds). + +Failure pattern the skill must counter: **extrapolation from Store 5 plus a rename premise**, with uncertainty disclosed in notes but a wrong deliverable still produced. + +## With skill: pass + +Run: 2026-08-15, fresh general-purpose agent, same scenario plus the skill directory in the sandbox (introduced only by its description, as an installed skill would be). + +The agent's notes source every Store 6 spelling to the skill files and reject the rename premise ("Store 6 is a redesigned API, not a package rename"). Its port met every pass criterion: `store { fetcher { }; persistence(...) }`, a `StoreKey` implementation, `org.mobilenativefoundation.store6.core`/`.core.seam` imports, `get(key, Freshness.MustBeFresh)` for pull-to-refresh, an exhaustive four-kind `StoreResult` `when` including `Revalidated`, both opt-ins on the seam implementation, `clearAll()` for sign-out, an added `close()` with an ownership note, and no invented API or coordinates. Its notes state verbatim: "I used no API that is absent from the skill." + +It did not copy the reference's worked port: for `cached(refresh = true)` it chose the table's invalidate-plus-default-stream pattern over the worked example's `MaxAge`, matched to the fixture's stated refresh-on-subscribe contract, and disclosed the resulting behavioral differences (stale data visible during revalidation, validator bound subsumed) instead of claiming equivalence. + +## Refactor pass + +The with-skill notes surfaced two places the agent had to guess. Both were closed in `references/store5-to-store6.md` after the run (the run above executed against the pre-edit skill): + +- Whether maintenance operations are suspending. Now stated: `invalidate*`/`clear*` suspend and can throw `StoreException`, and `close()` is a plain function. Also stated that a durable stale mark does not require a resident value. +- Whether `StoreException` exposes the underlying failure. Now stated: it carries `error: StoreError` and a nullable `cause`, with the catch-type migration consequence for Store 5 `fresh` callers. + +Future eval variant worth adding: a fixture the worked example does not mirror (no source of truth, `skipMemory` usage, or an Rx consumer) to test table use beyond the example. diff --git a/plugins/store/evals/migrating-to-store6/scenario.md b/plugins/store/evals/migrating-to-store6/scenario.md new file mode 100644 index 000000000..bad257fa8 --- /dev/null +++ b/plugins/store/evals/migrating-to-store6/scenario.md @@ -0,0 +1,28 @@ +# Eval: migrate a Store 5 screen under pressure + +Tests whether an agent migrates [fixtures/UserRepository.kt](fixtures/UserRepository.kt) to Store 6 correctly. The fixture covers the common translation surface: builder with source of truth, a 5-minute `Validator`, `cached(refresh = true)` screen subscription with an exhaustive response `when`, `fresh` pull-to-refresh, and `clearAll` sign-out. + +## Setup + +Copy the fixture into a sandbox directory the agent treats as an app repo. For the with-skill run, also copy the `migrating-to-store6` skill directory into the sandbox. + +## Prompt pressures + +The prompt combines three pressures: + +- **Authority:** the tech lead says Store 6 "is mostly a rename — same concepts, new package." +- **Time:** a teammate is blocked, so produce the file now rather than asking questions. +- **Isolation:** offline sandbox, no web access, Store repositories not on the machine (simulates a consumer environment where the agent cannot look the API up). + +The agent is told the `store6-core` dependency is on the classpath with packages under `org.mobilenativefoundation.store6.*`, asked to write the migrated file, and asked to record its confidence per API in a notes file. + +## Pass criteria (with skill) + +- Builder is the `store { fetcher { } }` DSL. No `StoreBuilder`, `Fetcher.of`, `SourceOfTruth.of`, or `Validator`. +- Key implements `StoreKey` with `namespace` and `canonicalId()`. +- Imports use `org.mobilenativefoundation.store6.core` (and `.core.seam` for the persistence seam), not `org.mobilenativefoundation.store6` directly. +- Pull-to-refresh is `get(key, Freshness.MustBeFresh)`. No `fresh` extension. +- Result handling is an exhaustive `when` over the four `StoreResult` kinds, including `Revalidated`. No `Initial`/`NoNewData` branches. +- Persistence goes through `persistence(...)` with `@OptIn(ExperimentalStoreApi::class)` (plus `DelicateStoreApi` where the seam is implemented). +- `clearAll()` kept for sign-out. The store gains a `close()` owner or the agent notes lifecycle ownership. +- No invented API and no invented dependency coordinates. diff --git a/plugins/store/plugin.json b/plugins/store/plugin.json new file mode 100644 index 000000000..b9c1a7b30 --- /dev/null +++ b/plugins/store/plugin.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "store", + "version": "0.3.0", + "description": "Skills for applications using Store (org.mobilenativefoundation.store): building a Store 6 data layer and migrating from Store 4/5.", + "author": { + "name": "Matt Ramotar", + "email": "matt.ramotar@uber.com" + }, + "repository": "https://github.com/matt-ramotar/Store6", + "license": "Apache-2.0", + "keywords": [ + "store", + "store6", + "kotlin", + "multiplatform", + "migration", + "caching", + "data-layer", + "offline", + "android", + "ios", + "compose" + ] +} diff --git a/plugins/store/skills/building-a-store6-data-layer/SKILL.md b/plugins/store/skills/building-a-store6-data-layer/SKILL.md new file mode 100644 index 000000000..02a4271ce --- /dev/null +++ b/plugins/store/skills/building-a-store6-data-layer/SKILL.md @@ -0,0 +1,63 @@ +--- +name: building-a-store6-data-layer +description: Use when adding Store 6 (org.mobilenativefoundation.store6) to an app or KMP module with no Store 4/5 code to migrate — designing a data layer or offline cache, modeling StoreKeys, choosing Freshness, wiring Room or SQLDelight persistence, consuming a store from Compose or Swift, or unsure whether a Store 6 API exists. For code that already uses Store 4/5, use migrating-to-store6. +--- + +# Building a Store 6 data layer + +## Overview + +Store 6 is pre-alpha and absent from training data. **If a spelling is not in this skill, its references, or verifiable Store 6 source, assume it does not exist and say so.** "Store 6 is Store 5 with a tidier builder" is false regardless of who says it. + +Route: legacy Store 4/5 code present → `migrating-to-store6`. + +## Ground truth + +- **Packages:** `org.mobilenativefoundation.store6.core` (core), `.core.seam` (expert seams), `.room`, `.sqldelight`, `.compose`. Types are not directly under `org.mobilenativefoundation.store6`. +- **Publishing:** nothing is published before `6.0.0-alpha01`. Do not write dependency coordinates from memory. +- **Builder:** `store { fetcher { key -> value } }`. A fetcher block is required; building without one throws `IllegalArgumentException`. Optional: `persistence(...)`, `bookkeeper(...)`. `Store.Builder`, `StoreBuilder.from`, `Fetcher.of`, `SourceOfTruth.of`, `Validator`, `.cachePolicy(...)`, `.memory(...)`, `.freshness(...)` on the builder, `RetryPolicy`, `MemoryPolicy`, `RoomPersister`, and `Read`/`ReadResult` do not exist. +- **Keys:** every key implements `StoreKey` (`namespace: StoreNamespace`, `canonicalId(): String`). `StoreNamespace` is a class: `StoreNamespace("users")` exposes `.value`. Canonical id is identity/dedup. Namespace is the bulk-operation unit. A bare `String` or a type that does not implement `StoreKey` does not satisfy `K : StoreKey`. +- **Reads:** freshness is per call: `stream(key, freshness)` and `get(key, freshness)`, default `Freshness.CachedOrFetch`. Exactly five policies: `CachedOrFetch`, `MaxAge(notOlderThan)`, `MustBeFresh`, `StaleIfError`, `LocalOnly`. +- **Results:** `StoreResult` has exactly four kinds: `Loading`, `Data(value, origin, age, isStale, refreshing)`, `Revalidated(age)`, `Error(error: StoreError, servedStale)`. `StoreError` is six frozen kinds (`Fetch`, `Persistence`, `Conversion`, `FreshnessUnsatisfiable`, `Conflict`, `Missing`). `stream` emits errors and never throws retrieval failures. `get` returns a value or throws `StoreException`. +- **Persistence:** seam `SourceOfTruth` via `persistence(...)`. Both `@ExperimentalStoreApi`; implementing the interface also needs `DelicateStoreApi`. Prefer `store6-room` or `store6-sqldelight`. Validate custom seams with `store6-testing`. +- **Maintenance:** `invalidate*` marks stale and keeps. `clear*` destroys. Decision test: clear when the value is wrong to show, invalidate when it is merely old. Maintenance ops suspend and can throw `StoreException`. `close()` is a plain function. +- **Engine behavior you do not build:** per-key single-flight dedup, stale-while-revalidate, durable invalidation, `maxIdleKeys` default 128 (idle-engine bound, **not** a data-lifetime or row-count cap). The engine retries the fetcher zero times and has no TTL/cache-policy knob. Retry/backoff/fallback belong inside the fetcher. + +## Workflow: design decisions in order + +1. **Model keys.** One namespace per record type. `canonicalId()` contains everything that changes the returned bytes. +2. **Write the fetcher.** Put retry, backoff, and fallback policy inside it. The engine will not retry. +3. **Choose persistence.** Default in-memory; or `store6-room` / `store6-sqldelight`; or a custom seam validated with the `store6-testing` contract kit. +4. **Choose per-read `Freshness` at each call site.** Offline-first → default `CachedOrFetch`. Bounded trust → `MaxAge`. User-forced refresh → `MustBeFresh`. Flaky-network tolerance → `StaleIfError`. Never fetch → `LocalOnly`. +5. **Place the code.** Keys, models, and the `store { }` definition live in the shared module's `commonMain`. Platform-constructed inputs (Room database instance, SQLDelight driver) are injected from platform source sets. One store instance is shared by Android and iOS. Room 3 KMP: common `@Database` + `@ConstructedBy` + platform `Room.databaseBuilder` actuals. +6. **Wire consumption** per platform — [references/compose.md](references/compose.md), [references/swift.md](references/swift.md). +7. **Assign a lifecycle owner** that calls `close()`. +8. **Wire maintenance.** Stale-not-wrong → `invalidate*`. Wrong-to-show → `clear*`. Sign-out → `clearAll()`. Push-driven staleness → `invalidate(key)`. + +## Common mistakes + +| Mistake | Reality | +| --- | --- | +| `Store.Builder` / `StoreBuilder.from` / `Fetcher.of` / builder `.freshness` / `Validator` / `.cachePolicy` | `store { fetcher { } }` (the DSL receiver is `StoreBuilder`, not a Store 5 factory). TTL is per-read `MaxAge` plus durable invalidation. | +| `RetryPolicy` / retry wrapper around the store | Retries live in the fetcher body. The engine retries zero times. | +| `MemoryPolicy(maxSize = 50)` or any row-count cap | No data-cap knob exists. `maxIdleKeys` (default 128) bounds idle engines, not rows. Say so honestly. | +| Bare `String` / data class key without `StoreKey` | Implement `StoreKey` (`namespace` + `canonicalId()`). Distinct record types get distinct namespaces. | +| Sign-out via `invalidateAll` / `Store.clear()` | `clearAll()`. Push-staleness is `invalidate(key)`, not a user-table `isStale` column. | +| `RoomPersister` / mutating user columns for Store metadata | `RoomSourceOfTruth` + `RoomBookkeeper`. Add sidecar entities; leave user tables untouched. | +| `Read` / `ReadResult` / three-kind `when` | `stream(key, freshness)` / `get(key, freshness)` and four `StoreResult` kinds including `Revalidated`. | +| Missing opt-in or `close()` | Persistence/adapters need `@OptIn(ExperimentalStoreApi::class)`. Someone must call `close()`. | + +## Red flags: stop and open a reference + +About to type `StoreBuilder.from`, `Store.Builder`, `Fetcher.of`, `SourceOfTruth.of`, `Validator`, `.cachePolicy`, `.ttl`, `.freshness(` on the builder, `RetryPolicy`, `MemoryPolicy`, `RoomPersister`, `Read.`/`ReadResult`, a retry/backoff argument on the builder, a bare-`String` key, or any `store6-room` / `store6-sqldelight` / compose / Swift name not in the references? Stop. Open the matching reference before writing the line. + +## References + +- [references/room.md](references/room.md): Room 3 sidecar schema, migration, `RoomSourceOfTruth` / `RoomBookkeeper` +- [references/sqldelight.md](references/sqldelight.md): generated-query wiring, `store6_meta*` sidecars, three boundary rules +- [references/compose.md](references/compose.md): `collectAsState` / lifecycle variants, four-kind UI, skip-equal-`Data` +- [references/swift.md](references/swift.md): SKIE `onEnum` case sets, `async throws` vs ObjC, `Duration`/`int64_t` trap + +--- + +Last verified against Store `main` @ `6790606d` (pre-`6.0.0-alpha01`). Re-verify spellings against the release you target. diff --git a/plugins/store/skills/building-a-store6-data-layer/references/compose.md b/plugins/store/skills/building-a-store6-data-layer/references/compose.md new file mode 100644 index 000000000..43707b4a1 --- /dev/null +++ b/plugins/store/skills/building-a-store6-data-layer/references/compose.md @@ -0,0 +1,81 @@ +# store6-compose + +Every spelling below is verified against Store `main` @ `6790606d`. Package `org.mobilenativefoundation.store6.compose`. Callers need `@OptIn(ExperimentalStoreApi::class)`. + +## Entry points + +All four are `@ExperimentalStoreApi` + `@Composable`, compiled in `commonMain`. + +| Receiver | Signature | Returns | +| --- | --- | --- | +| `Store` | `collectAsState(key, freshness = Freshness.CachedOrFetch, valueEquivalence = { a, b -> a == b })` | `State>` | +| `Flow>` | `collectAsStoreState(initial = StoreResults.loading(), valueEquivalence = { a, b -> a == b })` | `State>` | +| `Store` | `collectAsStateWithLifecycle(key, freshness = Freshness.CachedOrFetch, lifecycleOwner = LocalLifecycleOwner.current, minActiveState = Lifecycle.State.STARTED, valueEquivalence = { a, b -> a == b })` | `State>` | +| `Flow>` | `collectAsStoreStateWithLifecycle(initial = StoreResults.loading(), lifecycleOwner = LocalLifecycleOwner.current, minActiveState = Lifecycle.State.STARTED, valueEquivalence = { a, b -> a == b })` | `State>` | + +On targets without a UI host that populates `LocalLifecycleOwner`, pass `lifecycleOwner` explicitly (or use `collectAsState` / `collectAsStoreState`). + +```kotlin +val result by store.collectAsState(key) +val result by store.collectAsStateWithLifecycle(key) +``` + +## Four-kind UI + +Never merge kinds. Handle every `StoreResult` kind. `Success`, `Failure`, and `ReadResult` do not exist. + +| Kind | Fields | UI | +| --- | --- | --- | +| `Loading` | — | no servable value yet | +| `Data` | `value`, `origin`, `age`, `isStale`, `refreshing` | render the value | +| `Revalidated` | `age` | lifecycle signal: value confirmed fresh; nothing new to render | +| `Error` | `error`, `servedStale` | error UI | + +```kotlin +when (result) { + is StoreResult.Loading -> { /* placeholder */ } + is StoreResult.Data -> { /* value, origin, isStale, refreshing */ } + is StoreResult.Revalidated -> { /* still fresh; nothing new to render */ } + is StoreResult.Error -> { /* error, servedStale */ } +} +``` + +## Skip-equal-`Data` + +`StoreResult` types have identity equality (no `equals` override). These two `@ExperimentalStoreApi` helpers apply the same structural rule: + +| API | Role | +| --- | --- | +| `Flow>.skipEqualData(valueEquivalence = { a, b -> a == b })` | drops only consecutive structurally-equal `Data` frames | +| `storeResultMutationPolicy(valueEquivalence = { a, b -> a == b })` | the same rule for Compose `State` (`SnapshotMutationPolicy>`) | + +Structural compare on `Data`: `origin`, `isStale`, `refreshing`, `value` — **`age` excluded**. `Loading` / `Revalidated` / `Error` always pass. + +## Stability conf + +Copy the shipped snippet (`store6-compose/stability/store6-stability.conf`) into the **app** module as `store6-stability.conf`: + +``` +org.mobilenativefoundation.store6.core.* +org.mobilenativefoundation.store6.core.seam.* +``` + +```kotlin +composeCompiler { + stabilityConfigurationFiles.add( + layout.projectDirectory.file("store6-stability.conf"), + ) +} +``` + +What it changes: those packages compare as stable values (equal content, not equal instance). Strong skipping still works without it. + +## Flow vs State + +A `State` is a conflated container and can drop events. Event-shaped `Revalidated` / `Error` must collect the `Flow` (optionally with `skipEqualData`): + +```kotlin +store.stream(key) + .skipEqualData() + .collect { result -> /* four-kind when */ } +``` diff --git a/plugins/store/skills/building-a-store6-data-layer/references/room.md b/plugins/store/skills/building-a-store6-data-layer/references/room.md new file mode 100644 index 000000000..037461f78 --- /dev/null +++ b/plugins/store/skills/building-a-store6-data-layer/references/room.md @@ -0,0 +1,145 @@ +# Room 3 (`store6-room`) + +Sidecar schema + DAO wiring. Spellings below match Store `main` @ `6790606d`. + +Packages: `androidx.room3.*` (not `androidx.room`), `androidx.sqlite.SQLiteConnection`, `org.mobilenativefoundation.store6.room.*`. The builder is the top-level `store` function (`import org.mobilenativefoundation.store6.core.store`) — there is no `core.store` package. + +## Dependencies / plugin + +Replace `` with the release you target. Nothing is published before `6.0.0-alpha01`. Do not invent coordinates. + +```kotlin +plugins { + id("androidx.room3") // not Room 2's `room` + id("com.google.devtools.ksp") +} + +room3 { + schemaDirectory("$projectDir/schemas") +} + +kotlin { + sourceSets { + getByName("main") // KMP: the source set that owns @Database (often commonMain) + .languageSettings + .optIn("org.mobilenativefoundation.store6.core.ExperimentalStoreApi") + } +} + +dependencies { + implementation("org.mobilenativefoundation.store:store6-core:") + implementation("org.mobilenativefoundation.store:store6-room:") + ksp("androidx.room3:room3-compiler:3.0.0") +} +``` + +| Caveat | | +| --- | --- | +| Plugin | `androidx.room3`. Room 2's `room` / `room { }` is a different type identity. | +| Extension | `room3 { schemaDirectory(...) }` — not `room { }`. | +| Toolchain | Kotlin ≥ 2.3. Android: AGP ≥ 8.10. | +| Opt-in | Source-set `languageSettings.optIn("org.mobilenativefoundation.store6.core.ExperimentalStoreApi")` also covers generated DAO code. File-level `@OptIn` / `@file:OptIn` does not. | + +## Database diff (v1 → v2) + +Keep every user entity and DAO. Add two sidecar entities, one accessor, one version bump. + +```kotlin +@Database( + entities = [ + UserEntity::class, + Store6BookkeepingEntity::class, + Store6WatermarkEntity::class, + ], + version = 2, +) +abstract class AppDatabase : RoomDatabase() { + abstract fun userDao(): UserDao + abstract fun store6BookkeeperDao(): Store6BookkeeperDao +} +``` + +## Migration + +Room 3 `Migration.migrate` is suspend; `Store6RoomSchema.createTables` is not (`androidx.sqlite` 2.7.0 `execSQL` is synchronous). + +```kotlin +val addStore6Tables = object : Migration(1, 2) { + override suspend fun migrate(connection: SQLiteConnection) = + Store6RoomSchema.createTables(connection) +} + +Room.databaseBuilder(name = path) + .addMigrations(addStore6Tables) +``` + +## Schema + +Sidecars only. User tables are untouched. Do not add `isStale` / `updatedAt` columns to user tables for Store metadata. + +| Table | Owner | +| --- | --- | +| `store6_bookkeeping` | `Store6BookkeepingEntity` | +| `store6_watermarks` | `Store6WatermarkEntity` | +| user tables (e.g. `users`) | unchanged — no Store columns or constraints | + +`createTables` runs two `CREATE TABLE IF NOT EXISTS` statements. No `ALTER TABLE` on user schemas. + +## Wiring + +`RoomBookkeeper(database: RoomDatabase, dao: Store6BookkeeperDao)`. Pass the same `RoomDatabase` to both adapters. + +```kotlin +store { + fetcher { key -> api.getUser(key.id) } + persistence( + RoomSourceOfTruth( + database = database, + rowReader = { key -> dao.user(key.id).map { row -> row?.toUser() } }, + rowWriter = { _, user -> dao.upsert(user.toEntity()) }, + rowDeleter = { key -> dao.delete(key.id) }, + namespaceDeleter = { _ -> dao.deleteAll() }, + allDeleter = { dao.deleteAll() }, + ), + ) + bookkeeper(RoomBookkeeper(database, database.store6BookkeeperDao())) +} +``` + +| Adapter | Constructor | +| --- | --- | +| `RoomSourceOfTruth` | `(database, rowReader, rowWriter, rowDeleter, namespaceDeleter, allDeleter)` | +| `RoomBookkeeper` | `(database: RoomDatabase, dao: Store6BookkeeperDao)` | + +`RoomPersister` does not exist. + +## KMP placement + +| App | Pattern | +| --- | --- | +| KMP | Common `@Database` + `@ConstructedBy` + `expect object : RoomDatabaseConstructor` (`@Suppress("NO_ACTUAL_FOR_EXPECT")`; KSP writes the `actual`). Platform code builds the instance and injects it into common `store { }`. | +| JVM-only | Call `Room.databaseBuilder` in the same module. No `@ConstructedBy`. | + +```kotlin +@Database(entities = [UserEntity::class, Store6BookkeepingEntity::class, Store6WatermarkEntity::class], version = 2) +@ConstructedBy(AppDatabaseConstructor::class) +abstract class AppDatabase : RoomDatabase() { + abstract fun userDao(): UserDao + abstract fun store6BookkeeperDao(): Store6BookkeeperDao +} + +@Suppress("NO_ACTUAL_FOR_EXPECT") +expect object AppDatabaseConstructor : RoomDatabaseConstructor { + override fun initialize(): AppDatabase +} + +// platform actual — Room 3 KMP needs a driver; builder alone is not enough +Room.databaseBuilder(name = path) + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.Default) + .build() +``` + +## Testing + +Validate custom seams with `store6-testing` (`SourceOfTruthContractKit`, `BookkeeperContractKit`) at the same ``. diff --git a/plugins/store/skills/building-a-store6-data-layer/references/sqldelight.md b/plugins/store/skills/building-a-store6-data-layer/references/sqldelight.md new file mode 100644 index 000000000..9df479039 --- /dev/null +++ b/plugins/store/skills/building-a-store6-data-layer/references/sqldelight.md @@ -0,0 +1,54 @@ +# store6-sqldelight + +Every spelling below is verified against Store `main` @ `6790606d`. Package `org.mobilenativefoundation.store6.sqldelight`. Both adapters are `@ExperimentalStoreApi` — callers need `@OptIn(ExperimentalStoreApi::class)`. + +## Construction + +| Adapter | Constructor | +| --- | --- | +| `SqlDelightSourceOfTruth` | `(driver, transacter, readQuery, writeRow, deleteRow, deleteNamespaceRows, deleteAllRows)` | +| `SqlDelightBookkeeper` | `(driver: SqlDriver, transacter: Transacter)` | + +Optional on `SqlDelightSourceOfTruth` only: `wallClock: WallClock? = null`, `readContext: CoroutineContext = Dispatchers.Default`. Do not require them. + +Call-site may pass the generated database as the transacter: `SqlDelightBookkeeper(driver, db)`. + +```kotlin +val sot = SqlDelightSourceOfTruth( + driver = driver, + transacter = db, + readQuery = { key -> db.userQueries.selectById(key.id) { id, name, email -> User(id, name, email) } }, + writeRow = { _, user -> db.userQueries.upsert(user.id, user.name, user.email) }, + deleteRow = { key -> db.userQueries.deleteById(key.id) }, + deleteNamespaceRows = { ns -> if (ns.value == "users") db.userQueries.deleteAll() }, + deleteAllRows = { db.userQueries.deleteAll() }, +) +val store = store { + fetcher { key -> fakeApi.user(key.id) } + persistence(sot) + bookkeeper(SqlDelightBookkeeper(driver, db)) +} +``` + +## Sidecar tables + +Adapter creates four tables. No `.sq` changes. `user_version` is never touched. + +| Table | +| --- | +| `store6_meta_schema` | +| `store6_meta_sequence` | +| `store6_meta` | +| `store6_meta_watermark` | + +## Boundary rules + +| Rule | Law | +| --- | --- | +| Round trip | After `writeRow(key, value)` returns, `readQuery(key)` must return the equivalent `value`. | +| One `SqlDriver` | `driver`, `transacter`, every generated query, every mutation callback, and `SqlDelightBookkeeper` must use the same `SqlDriver`. | +| `withTransaction` is synchronous | A block that genuinely suspends throws `IllegalStateException`, cancels its child job, and rolls the transaction back. | + +## Instances + +Use one logical Store per database and namespace set. Instances sharing a database also share the sidecar's monotone sequence and watermarks. diff --git a/plugins/store/skills/building-a-store6-data-layer/references/swift.md b/plugins/store/skills/building-a-store6-data-layer/references/swift.md new file mode 100644 index 000000000..096e16411 --- /dev/null +++ b/plugins/store/skills/building-a-store6-data-layer/references/swift.md @@ -0,0 +1,68 @@ +# Store 6 from Swift + +Spellings from committed dumps `store6-core/api/swift/skie/Store6CoreSkie.swift` and `store6-core/api/swift/objc/Store6Core.h` at Store `main` @ `6790606d`. If a name is not here, it does not exist. + +## `onEnum(of:)` case sets + +SKIE `@frozen __Sealed` enums. Switch with `onEnum(of:)`. An unknown sealed subtype hits `fatalError` inside `onEnum` — a `default` branch is not a safety net. + +| Type | Cases | +| --- | --- | +| `StoreResult` | `data`, `error`, `loading`, `revalidated` | +| `Freshness` | `cachedOrFetch`, `localOnly`, `maxAge`, `mustBeFresh`, `staleIfError` | +| `StoreError` | `conflict`, `conversion`, `fetch`, `freshnessUnsatisfiable`, `missing`, `persistence` (frozen for 6.x) | +| `FetcherResult` | `deleted`, `error`, `notModified`, `success` | + +```swift +switch onEnum(of: result) { +case .data(let data): /* data.value, .origin, .age, .isStale, .refreshing */ +case .error(let failure): switch onEnum(of: failure.error) { /* six StoreError cases */ } +case .loading: break +case .revalidated(let frame): /* frame.age */ +} +``` + +## Suspend ops, `close()`, `stream` + +| Op | SKIE | ObjC | +| --- | --- | --- | +| `get(key:freshness:)` | `async throws -> Any` | `get(key:freshness:completionHandler:)` | +| `invalidate(key:)` / `invalidateNamespace(namespace:)` / `invalidateAll()` | `async throws` | matching `…(completionHandler:)` | +| `clear(key:)` / `clearNamespace(namespace:)` / `clearAll()` | `async throws` | matching `…(completionHandler:)` | +| `close()` | synchronous — no async wrapper | `close()` — no completion handler | +| `stream(key:freshness:)` | returns `Kotlinx_coroutines_coreFlow` | same; not a completion-handler op | + +`stream` itself is **not** `AsyncSequence`. Wrap the returned `Kotlinx_coroutines_coreFlow` in `SkieSwiftFlow` (`SkieSwiftFlowProtocol` : `AsyncSequence`; public convenience init takes `SkieKotlinFlow`). Iteration is task-scoped; cancellation is wired through the SKIE iterator. Flow elements are erased (`Any` / `AnyObject`) — do not invent a typed `StoreResult` generic if the dump does not give you one. + +```swift +let flow = store.stream(key: key, freshness: freshness) // Kotlinx_coroutines_coreFlow +for await result in SkieSwiftFlow(SkieKotlinFlow(flow)) { + switch onEnum(of: result) { /* four StoreResult cases */ } +} +``` + +## `int64_t` trap — `Duration` raw vs epoch millis + +ObjC export flattens both Kotlin `Duration` and `Long` to `int64_t`. They are not the same unit. Do not pass a bare integer like `5000` as if it were seconds or millis for a `Duration` field. + +| Field | `int64_t` means | +| --- | --- | +| `Freshness.MaxAge.notOlderThan` | Kotlin `Duration` **raw representation** (not millis, not seconds) | +| `StoreResult.Data.age` | Kotlin `Duration` **raw representation** | +| `StoreResult.Revalidated.age` | Kotlin `Duration` **raw representation** | +| `StoreMeta.writtenAtEpochMillis` | Unix epoch **milliseconds** | +| `Bookkeeper.recordFailure` `atEpochMillis` | Unix epoch **milliseconds** | +| `WallClock.nowEpochMillis` | Unix epoch **milliseconds** | + +Construct `Duration` in Kotlin. Read epoch-millis fields as Unix milliseconds. + +## Exception boundary + +| Lane | Cancellation | Other uncaught Kotlin exceptions | +| --- | --- | --- | +| ObjC | `CancellationException` → `NSError` | **fatal**. `StoreException` from `get` is fatal, not an `NSError`. | +| SKIE | → `CancellationError` | unexpected errors during flow `hasNext()` → `fatalError` | + +## One failure channel + +`stream` emits and never throws retrieval failures. `get` throws and never emits. diff --git a/plugins/store/skills/migrating-to-store6/SKILL.md b/plugins/store/skills/migrating-to-store6/SKILL.md new file mode 100644 index 000000000..3d4c76a36 --- /dev/null +++ b/plugins/store/skills/migrating-to-store6/SKILL.md @@ -0,0 +1,63 @@ +--- +name: migrating-to-store6 +description: Use when migrating Kotlin code from Store 4 or Store 5 (org.mobilenativefoundation.store) to Store 6, when Store 5 spellings like StoreBuilder.from, Fetcher.of, SourceOfTruth.of, StoreReadRequest, StoreReadResponse, MutableStore, Validator, or store.fresh appear in code that should target Store 6, or when writing Store 6 code and unsure whether an API exists. +--- + +# Migrating to Store 6 + +## Overview + +Store 6 is a redesigned API, not a renamed Store 5. **The Store 5 builder, request, and response vocabulary does not exist in Store 6.** A package-rename port cannot compile. Migrate by translating with the tables in [references/store5-to-store6.md](references/store5-to-store6.md), one complete screen at a time. + +**Never invent a Store 6 API.** If a spelling is not in this skill, its references, or the Store 6 sources you can verify against, assume it does not exist and say so instead of guessing. "Store 6 is mostly a rename" is false regardless of who says it. + +## Ground truth + +- **Packages:** `org.mobilenativefoundation.store6.core` (core) and `org.mobilenativefoundation.store6.mutations` (experimental writes). Types are not directly under `org.mobilenativefoundation.store6`. +- **Publishing:** nothing is published before `6.0.0-alpha01`. Do not write dependency coordinates from memory. Verify them against the release you target. Store 5 coordinates stay published for the whole 6.x major, so both can coexist in one build. +- **Builder:** `store { fetcher { key -> value } }`. A fetcher block is the one required input, and building without one throws `IllegalArgumentException`. `StoreBuilder.from`, `Fetcher.of`, `SourceOfTruth.of`, `Validator.by`, `.validator(...)`, `.scope(...)`, and `.cachePolicy(...)` do not exist. +- **Keys:** every key implements `StoreKey` (`namespace: StoreNamespace`, `canonicalId(): String`). A key type that does not implement `StoreKey`, such as a plain `String`, does not satisfy the `K : StoreKey` bound. +- **Reads:** freshness is per call: `stream(key, freshness)` and suspending `get(key, freshness)`, default `Freshness.CachedOrFetch`. `StoreReadRequest` does not exist. Exactly five policies: `CachedOrFetch`, `MaxAge(notOlderThan)`, `MustBeFresh`, `StaleIfError`, `LocalOnly`. +- **Results:** `StoreResult` has exactly four kinds: `Loading`, `Data(value, origin, age, isStale, refreshing)`, `Revalidated(age)`, `Error(error, servedStale)`. `StoreReadResponse`, `Initial`, `NoNewData`, `requireData()`, and `dataOrNull()` do not exist. One failure channel: `stream` emits errors and never throws retrieval failures. `get` returns a value or throws `StoreException`. +- **Persistence:** the seam `SourceOfTruth` is installed via `persistence(...)`. Both are `@ExperimentalStoreApi`, and implementing the interface additionally requires opt-in to `DelicateStoreApi`. Prefer the `store6-room` or `store6-sqldelight` adapters. Validate custom implementations with the `store6-testing` contract kit. +- **Maintenance:** `invalidate`/`invalidateNamespace`/`invalidateAll` mark data stale and keep it. `clear`/`clearNamespace`/`clearAll` destructively remove it. Decision test: clear when the value is wrong to show, invalidate when it is merely old. Lifecycle is explicit: release a store with `close()`. +- **Engine behavior you no longer build:** per-key single-flight fetch deduplication, stale-while-revalidate, durable invalidation across restarts, and a quiescent idle-key bound (`maxIdleKeys`, default 128). Store performs no retries and no fallback chain. That policy belongs inside your fetcher. + +## Workflow: one screen at a time + +1. Keep the Store 5 dependency and its working screens in place. Add Store 6 alongside. +2. Design the `StoreKey`. The namespace is what you invalidate together, and `canonicalId()` includes everything that can change the returned value. +3. Port the fetcher. Move retry, backoff, and fallback policy inside it. +4. Wire persistence through an adapter or the seam. +5. Translate read sites and result handling with [references/store5-to-store6.md](references/store5-to-store6.md). +6. Translate maintenance calls (invalidate vs clear) and give the store an owner that calls `close()`. +7. Delete that screen's Store 5 store, then repeat. Until a Store 5 interop artifact ships, the two versions do not share cache state. Move whole screens, never one screen's fetch/persist/read sites split across versions. + +## Common mistakes + +| Mistake | Reality | +| --- | --- | +| Port by renaming imports ("it's mostly a rename") | The renamed spellings do not exist, so the port cannot compile. Translate with the tables instead. | +| `store.fresh(id)` or `impl.extensions` imports | `get(key, Freshness.MustBeFresh)` | +| Treating `StoreReadRequest.cached(key, refresh = true)` as one call | No single equivalent: collect the default `stream(key)` and call `invalidate(key)` when the caller asks to refresh | +| `when` over only Loading/Data/Error | `Revalidated` is a fourth kind: the value was confirmed fresh, there is nothing new to render | +| Passing a `String` key | Implement `StoreKey` | +| Carrying `Validator` over | Per-call `Freshness` (usually `MaxAge`) plus durable invalidation | +| Assuming no opt-ins | Persistence, seams, and all of mutations require `@OptIn(ExperimentalStoreApi::class)` | +| Translating `NoNewData` | No analog: a Store 6 fetcher has no empty-flow outcome | +| Adding TTL cache config or retry wrappers around the store | No cache policy knob exists (`maxIdleKeys` bounds idle engines, not data lifetime). Retries live inside the fetcher | + +## Red flags: stop and open the tables + +About to type `StoreBuilder`, `Fetcher.of`, `SourceOfTruth.of`, `StoreReadRequest`, `StoreReadResponse`, `Validator`, `store.fresh(`, or a bare-`String` key against Store 6? Those are Store 5 spellings. Open [references/store5-to-store6.md](references/store5-to-store6.md) before writing the line. + +## References + +- [references/store5-to-store6.md](references/store5-to-store6.md): full translation tables and a worked before/after port +- [references/component-map.md](references/component-map.md): all eight Store 5 components, row by row +- [references/from-store4.md](references/from-store4.md): the Store 4 starting point and which rows to skip +- [references/mutations.md](references/mutations.md): `MutableStore`/`Updater` to the journalled mutation path (experimental) + +--- + +Last verified against Store `main` @ `c67a94ed` (pre-`6.0.0-alpha01`). Re-verify spellings against the release you target. diff --git a/plugins/store/skills/migrating-to-store6/references/component-map.md b/plugins/store/skills/migrating-to-store6/references/component-map.md new file mode 100644 index 000000000..b2a29c2d8 --- /dev/null +++ b/plugins/store/skills/migrating-to-store6/references/component-map.md @@ -0,0 +1,51 @@ +# Store 5 components → Store 6, row by row + +Store 5 documents eight components: Store, Fetcher, SourceOfTruth, Converter, Validator, MutableStore, Updater, and Bookkeeper. Seven rows cover them because `MutableStore` and `Updater` move together. + +| Store 5 component | Store 6 replacement | +| --- | --- | +| `Store` | `Store` with `stream(key, freshness)`, suspending `get(key, freshness)`, namespace-aware `invalidate*`/`clear*`, and explicit `close()` | +| `Fetcher` | `fetcher { }`, `fetcherOfResult { }`, or the experimental seam `Fetcher` (receives conditional-request ETags). Last registration wins across all three. | +| `SourceOfTruth` | The seam `SourceOfTruth` installed via `persistence(...)`, or a `store6-room`/`store6-sqldelight` adapter | +| `Converter` | **No direct analog.** Mapping lives in fetcher and persistence callbacks. | +| `Validator` | Native per-call `Freshness`, durable invalidation, and the expert `FreshnessValidator` read-planning seam | +| `MutableStore` + `Updater` | `mutationStore` plus typed `mutate`. **Updater has no direct analog:** its transport job moves to an app-owned `MutationServer` invoked by foreground `drain`. See [mutations.md](mutations.md). | +| Failed-sync `Bookkeeper` | **No direct analog.** Durable mutation-journal records and inspection replace the job. | + +## Store → Store + +Store 5 centers reads on `stream(StoreReadRequest)`. Store 6 uses `stream(key, freshness)` and adds suspending `get(key, freshness)`. One failure channel: `stream` emits `StoreResult.Error` and never throws retrieval failures (a `MustBeFresh` initial-cycle failure emits one error and completes the flow). `get` returns a value or throws `StoreException` and never emits a wrapper. + +`clear(key)`/`clearAll()` become two families. `invalidate*` marks stale and preserves values. `clear*` destructively removes values and their per-key freshness records. Namespace and global stale watermarks are conservative and are not reset by clear operations. + +## Fetcher → fetcher, fetcherOfResult, or the seam + +Store 5's `FetcherResult.Data` and three error shapes become `FetcherResult.Success(value, etag)` and `FetcherResult.Error(cause)`. Store 6 adds `NotModified(etag)`, which produces one `StoreResult.Revalidated`, and `Deleted`, which clears the resident value without an automatic refetch. + +No engine-level fallback chain exists. Store performs zero retries. Compose retry, backoff, or fallback endpoints inside your fetcher. + +## SourceOfTruth → the persistence seam + +Store 5's `SourceOfTruth` had separate local and output types, with a `Converter` bridging the fetcher's network type. The Store 6 seam is `SourceOfTruth` with one value type and a nullable-row reader. + +The contract: `reader(key)` immediately first-emits the current row or `null`, stays live, and publishes changes made through that instance. Mutations provide read-your-writes on normal return and are exception-atomic, including cancellation. `deleteNamespace` is new. Validate implementations with the `store6-testing` contract kit. + +## Converter → callbacks + +No converter seam exists. A store is typed on one value `V`: map network payloads to `V` inside the fetcher, and map `V` to and from database rows inside the persistence adapter's callbacks. The conversion still exists. It is owned at the boundary where the representation changes. + +## Validator → native freshness + +Store 5's `Validator.isValid(item)` asked one per-item question. Store 6 plans each read from resident availability, freshness metadata, durable staleness, and one of five per-call policies (`CachedOrFetch`, `MaxAge`, `MustBeFresh`, `StaleIfError`, `LocalOnly`). Every `StoreResult.Data` reports `isStale`, and `invalidate*` records staleness directly. + +The experimental `FreshnessValidator` seam is not a per-item validity hook: its pure `plan(context)` returns a fetch plan (`Skip`, `Fetch`, or `Conditional`) for one coherent read snapshot. Most applications should use the native policies. + +## Bookkeeper → the journal, with a name collision + +Store 5's `Bookkeeper` recorded failed-sync timestamps so later reads could detect unsynced local changes. No Store 6 component has that job. The mutation journal replaces the system with durable intents, attempt generations, acknowledgement progress, normalized failures, and retirement, inspected through `pending(key)`, `pendingWrites()`, and `deadLetters()`. + +Name collision: Store 6 core also has a type named `Bookkeeper`, but it records freshness metadata, per-key stale marks, and namespace/global watermarks. It does not track failed write synchronization. + +## Not components in Store 6 + +Memory-cache configuration: `maxIdleKeys` (default 128) bounds quiescent per-key engine residency. Eviction discards derived engine state, never durable rows, metadata, stale marks, or watermarks. Builder `scope(...)`: no counterpart. Stores own their work and release it through `close()`. diff --git a/plugins/store/skills/migrating-to-store6/references/from-store4.md b/plugins/store/skills/migrating-to-store6/references/from-store4.md new file mode 100644 index 000000000..4d610aa53 --- /dev/null +++ b/plugins/store/skills/migrating-to-store6/references/from-store4.md @@ -0,0 +1,25 @@ +# Starting from Store 4 + +Store 4 used `com.dropbox` packages. Two release statements bound this path: Store 5's early multiplatform release states that concepts and usage were unchanged from Store 4, and the Store 5 stable release describes its additions over Store 4 as having no breaking changes. The [Store 5 translation tables](store5-to-store6.md) therefore apply to a Store 4 codebase directly. Skip the Store 5-only rows instead of migrating to Store 5 first. + +## Rows that do not apply to you + +Store 5 added `MutableStore`, `Validator`, fallback mechanisms, write-conflict resolution, and `NoNewData` after Store 4. A Store 4 application has none of these to translate. Per-call freshness policies and the journalled mutation path are new capabilities to evaluate, not behaviors to port. + +## The rows that remain + +| Store 4-era responsibility | Store 6 path | +| --- | --- | +| Fetcher | `fetcher { }`, `fetcherOfResult { }`, or the experimental seam `Fetcher` | +| Persister / SourceOfTruth | The persistence seam or a `store6-room`/`store6-sqldelight` adapter | +| Converter | No direct analog. Conversion lives in fetcher and persistence callbacks | +| Read sites | `get(key, freshness)` for a point read, `stream(key, freshness)` for an ongoing flow | + +Two Store 4-era read patterns and their translations: + +- `store.fresh(key)` (a Store 4 extension retained through Store 5) → `get(key, Freshness.MustBeFresh)` when the caller needs one fresh value. +- `store.stream(StoreRequest.cached(key, refresh = true))` (`StoreRequest` is Store 4's request type) → an ongoing `stream(key)` plus deliberate `invalidate(key)` when the caller requests a refresh. Not a one-call mechanical rename. + +## Coexistence + +Store 6 uses group `org.mobilenativefoundation.store` and packages under `org.mobilenativefoundation.store6.*`. Nothing is published before `6.0.0-alpha01`. The formal side-by-side coexistence promise begins with Store 5. No Store 4 artifact availability or interop is guaranteed. If the Store 4 dependency still resolves, keep it in place and move one complete screen at a time. diff --git a/plugins/store/skills/migrating-to-store6/references/mutations.md b/plugins/store/skills/migrating-to-store6/references/mutations.md new file mode 100644 index 000000000..44a0d69ff --- /dev/null +++ b/plugins/store/skills/migrating-to-store6/references/mutations.md @@ -0,0 +1,46 @@ +# MutableStore and Updater → the journalled mutation path + +`store6-mutations` is a separate experimental artifact: every public symbol carries `@ExperimentalStoreApi`, so shapes may change or be removed in any release. Confirm the consuming team has accepted that before porting writes. Reads can migrate to core first. + +`mutationStore(...)` returns a `MutationStore`, which implements `Store`, so read, freshness, invalidation, clear, and close behavior follows the core read contract. + +## The write vocabulary + +Store 5's `MutableStore.write(...)` and `Updater.post(...)` move into one journalled path: + +1. Register named, typed write shapes once in a `MutatorRegistry` (`mutator`, `update`, `create`, `delete`, or `upsert`). No call-site closure becomes a durable intent. `update` declines when the confirmed base is absent, `delete` always applies absence, `upsert` cannot decline. +2. Enqueue with `mutate(key, ref, args)`. It returns an opaque mutation id and does not push. +3. Push one foreground pass with `drain(key)` or `drain()`. A drain performs no retry or backoff and never fetches. +4. Implement the app-owned `MutationServer` transport contract: exactly two methods, `push(request): MutationAck` and `retire(request): MutationRetirementAck`. +5. Inspect durable truth with `pending(key)`, `pendingWrites()`, and `deadLetters()`. + +Restart-safe key recovery is compile-time required. The registry, server, key resolver, and value codec/version are factory inputs: + +```kotlin +@OptIn(ExperimentalStoreApi::class) // the whole module is experimental +val users = mutationStore( + registry = registry, + server = server, + keyResolver = MutationKeyResolver { identity -> UserKey(identity.canonicalId) }, + valueCodecVersion = 1, + valueCodec = userJsonCodec, +) { + fetcher { key -> api.load(key) } +} + +users.mutate(key, renameRef, Rename("new name")) // journalled — the only write path +users.drain(key) // push pending intents and adopt each ack +``` + +## Contract points that differ from Store 5 + +- Optimistic values appear only on `stream`, with `origin == Origin.OVERLAY`, `age = Duration.ZERO`, and `isStale = false`. `get` remains a point read of committed truth. Drive pending-write UI from the origin, never from `isStale`. +- `runtime()` on a mutation store returns `null`: consumer writes cannot bypass the journal through the raw engine write handle. +- Conflict handling is an optional `conflicts { precondition(...); merge(...) }` block. Without a registered merge, server-wins is the non-removable terminal. +- The per-key write queue becomes a durable FIFO ordered by client sequence. +- The default journal is in-memory. Use the SQLDelight journal adapter (or another conforming durable implementation) when queued work must survive process restart. +- The server contract requires idempotency: a repeated idempotency key must be treated as the same request. If remote acceptance lands before the local acknowledgement-receipt commits, the durable phase stays `INFLIGHT` and a later drain may replay the same immutable generation. Once `ACKED` is durable, recovery may repeat local adoption, effects, and retirement, but never calls `MutationServer.push` again for that generation. + +## Store 5 Bookkeeper users + +The failed-sync `Bookkeeper` job is replaced by the journal's durable records and the inspection surfaces above, not by a component with the same name. See the name-collision note in [component-map.md](component-map.md). diff --git a/plugins/store/skills/migrating-to-store6/references/store5-to-store6.md b/plugins/store/skills/migrating-to-store6/references/store5-to-store6.md new file mode 100644 index 000000000..d37ca9d81 --- /dev/null +++ b/plugins/store/skills/migrating-to-store6/references/store5-to-store6.md @@ -0,0 +1,216 @@ +# Store 5 to Store 6 translation tables + +Every spelling below is verified against Store `main` @ `c67a94ed`. Rows marked "nearest translation" are intent translations, not behavioral equivalents. The differences are stated in the row. + +## Imports + +| Store 5 | Store 6 | +| --- | --- | +| `org.mobilenativefoundation.store.store5.*` | `org.mobilenativefoundation.store6.core.*` (core), `org.mobilenativefoundation.store6.core.seam.*` (expert seams), `org.mobilenativefoundation.store6.mutations.*` (experimental writes) | +| `org.mobilenativefoundation.store.store5.impl.extensions.fresh` | Does not exist. Use `get(key, Freshness.MustBeFresh)`. | + +## Builder + +Store 5: + +```kotlin +val store = StoreBuilder + .from(fetcher = Fetcher.of { key -> api.fetch(key) }, sourceOfTruth = sot) + .build() +``` + +Store 6: + +```kotlin +val store = store { + fetcher { key -> api.fetch(key.id) } // the one required input +} +``` + +| Store 5 builder setting | Store 6 | +| --- | --- | +| `Fetcher.of { }` | `fetcher { key -> value }` (success-or-throw sugar) | +| `Fetcher.ofResult { }` | `fetcherOfResult { key -> FetcherResult }` with the full result vocabulary: `Success(value, etag)`, `Error(cause)`, `NotModified(etag)` (emits `Revalidated`), `Deleted` (clears without refetch) | +| Fallback fetcher chains | No engine support. Store performs zero retries and no fallback chain. Compose them inside your fetcher. | +| `sourceOfTruth = SourceOfTruth.of(reader, writer, delete, deleteAll)` | `persistence(sot)` where `sot` implements the seam interface `SourceOfTruth` (`reader(key): Flow`, `write(key, value)`, `delete(key)`, `deleteNamespace(namespace)`, `deleteAll()`). `persistence` and the interface are `@ExperimentalStoreApi`, and implementing the interface additionally requires `DelicateStoreApi` opt-in. Prefer the `store6-room`/`store6-sqldelight` adapters. | +| `validator(Validator.by { ... })` | No per-item validity hook. Use per-call `Freshness` (usually `MaxAge`) plus `invalidate*`. The experimental `FreshnessValidator` seam is a read planner, not a validity check. | +| `scope(...)` | No counterpart. The store owns its lifecycle. Release it with `close()`. After close, operations fail with `IllegalStateException("Store is closed.")`. | +| `cachePolicy(...)` / `disableCache()` | No TTL cache policy. `maxIdleKeys(count)` (default 128) bounds quiescent per-key engine residency, and `0` destroys each engine at quiescence. Eviction discards derived state only. Durable rows, stale marks, and watermarks survive. | + +Keys change shape. Store 5 accepts any non-null key. A Store 6 key implements `StoreKey` and supplies `namespace` and `canonicalId()`, which together form the durable identity. + +```kotlin +class UserKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + override fun canonicalId(): String = id +} +``` + +## Read requests → Freshness + +Store 5 puts cache and fetch choices in `StoreReadRequest`. Store 6 puts a `Freshness` policy on each call. Calls with different policies still share one in-flight fetch per key. + +| Store 5 request | Store 6 translation | +| --- | --- | +| `StoreReadRequest.cached(key, refresh = false)` | Nearest translation: `stream(key)` or `get(key)` with default `Freshness.CachedOrFetch`. Still fetches when nothing is local, but may also serve-and-background-revalidate invalidated or metadata-less local residence. | +| `StoreReadRequest.fresh(key)` | Nearest translation: `Freshness.MustBeFresh`, which withholds residence and blocks for a fresh fetch. Store 5 could emit `NoNewData` on an empty fetcher flow. Store 6 has no empty-flow outcome: a failed `MustBeFresh` read emits one `StoreResult.Error` and completes the flow (`stream`) or throws `StoreException` (`get`). | +| `StoreReadRequest.localOnly(key)` | `Freshness.LocalOnly`: never invokes the fetcher, probes persistence once on a memory miss, reports `StoreError.Missing` when nothing is local. | +| `StoreReadRequest.fresh(key, fallBackToSourceOfTruth = true)` | Nearest policy: `Freshness.StaleIfError`, which prefers fresh and falls back to the stale local value when the fetch fails. Not an exact equivalence. | +| `StoreReadRequest.cached(key, refresh = true)` | No single policy. For caller-initiated refresh, call `invalidate(key)` and keep collecting the default stale-while-revalidate stream. | +| `StoreReadRequest.skipMemory(key, refresh)` | No equivalent. Store 6 does not expose per-call skipping of storage layers. | + +## Read responses → StoreResult + +| Store 5 | Store 6 | +| --- | --- | +| `Initial` / `Loading` | `Loading`: emitted when demand exists and no value is servable. No separate `Initial` kind. | +| `Data(value, origin)` | `Data(value, origin, age, isStale, refreshing)` | +| `NoNewData` | No analog (it meant a Store 5 fetcher flow completed without data). | +| `Error.Exception` / `Error.Message` / `Error.Custom` | `Error(error: StoreError, servedStale)`. `StoreError` has six variants: `Fetch`, `Persistence`, `Conversion`, `FreshnessUnsatisfiable`, `Conflict`, `Missing`. Each variant carries a `message`, the sealed base does not, so match exhaustively. `servedStale` is true when a stale resident was served and its refresh then failed under a stale-tolerant policy. | +| No analog | `Revalidated(age)`: the not-modified result of a conditional fetch. Clears staleness without emitting redundant `Data`. Handle it in every exhaustive `when`. | + +Origins: + +| Store 5 origin | Store 6 `Origin` | +| --- | --- | +| `Cache` | `MEMORY` | +| `SourceOfTruth` | `SOT` | +| `Fetcher(name)` | `FETCHER` | +| `Initial` | No analog. Store 6 has no `Initial` response kind, and `Loading` carries no origin. | +| No analog | `OVERLAY`: an optimistic projection above committed data, visible on `stream` only. The mutation engine installs one, and core's experimental `overlay(...)` builder seam can too. | + +Helpers `requireData()`, `dataOrNull()`, and `throwIfError()` do not carry over. The two doors replace them. `get` returns a value or throws `StoreException`. `stream` emits results and never throws retrieval failures: a `Freshness.MustBeFresh` initial-cycle failure emits one error and completes the flow, and every other failure leaves the flow live. + +`StoreException` exposes the structured failure as `error: StoreError` and the underlying failure through the standard exception `cause` (nullable). Store 5 callers that caught the fetcher's own exception type (for example `IOException`) from `fresh` must catch `StoreException` and inspect `error` or `cause` instead. + +## Maintenance + +| Store 5 | Store 6 | +| --- | --- | +| `clear(key)` used to force a refresh | `invalidate(key)`: marks stale, keeps the value visible, triggers exactly one refresh for live streams. Also `invalidateNamespace(namespace)`, `invalidateAll()`. Stale marks are durable across restarts and do not require a resident value: a key is durably stale when its mark or a namespace/global watermark is newer than its last successful fetch, so invalidating a not-yet-fetched key is valid and applies to future reads. | +| `clear(key)` / `clearAll()` used to remove data | `clear(key)`, `clearNamespace(namespace)`, `clearAll()`: destructively remove values and their per-key freshness records. A post-clear stream never replays pre-clear data. | +| (no counterpart) | `close()`: releases the store. Subsequent operations throw `IllegalStateException`. | + +All `invalidate*` and `clear*` operations are suspending and can throw `StoreException` when persisting the mark or performing durable deletion fails. `close()` is a plain function. + +Decision test: if the value is wrong to show, clear it. If it is merely imperfect or old, invalidate it. + +## Worked port + +The Store 5 file below is a common screen shape: builder with source of truth, a 5-minute `Validator`, `cached(refresh = true)` for the screen subscription, `fresh` for pull-to-refresh, `clearAll` on sign-out. + +```kotlin +// Store 5 (before) +private val store = StoreBuilder + .from( + fetcher = Fetcher.of { id: String -> api.fetchUser(id) }, + sourceOfTruth = SourceOfTruth.of( + reader = { id -> dao.observeUser(id) }, + writer = { _, user -> dao.upsert(user) }, + delete = { id -> dao.delete(id) }, + deleteAll = { dao.deleteAll() }, + ), + ) + .validator(Validator.by { user -> nowMillis() - user.updatedAtMillis < 5.minutes.inWholeMilliseconds }) + .build() + +fun observeUser(id: String): Flow = + store.stream(StoreReadRequest.cached(key = id, refresh = true)).map { response -> + when (response) { + is StoreReadResponse.Initial, is StoreReadResponse.Loading -> UserUiState.Loading + is StoreReadResponse.Data -> UserUiState.Loaded(response.value, response.origin is StoreReadResponseOrigin.Cache) + is StoreReadResponse.NoNewData -> UserUiState.Loading + is StoreReadResponse.Error.Exception -> UserUiState.Failed(response.error.message ?: "Unknown error") + is StoreReadResponse.Error.Message -> UserUiState.Failed(response.message) + is StoreReadResponse.Error.Custom<*> -> UserUiState.Failed("Unknown error") + } + } + +suspend fun refreshUser(id: String): User = store.fresh(id) +suspend fun onSignOut() = store.clearAll() +``` + +The Store 6 port. It translates intent and is not row-for-row behaviorally identical. Differences are noted inline: + +```kotlin +// Store 6 (after) +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.store + +class UserKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + override fun canonicalId(): String = id +} + +// Implementing the seam requires both opt-ins. Validate custom implementations with the +// store6-testing contract kit; prefer the store6-room/store6-sqldelight adapters when the app +// already has a database. +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +private class UserDaoSourceOfTruth(private val dao: UserDao) : SourceOfTruth { + override fun reader(key: UserKey): Flow = dao.observeUser(key.id) + override suspend fun write(key: UserKey, value: User) = dao.upsert(value) + override suspend fun delete(key: UserKey) = dao.delete(key.id) + // This store has a single namespace, so both scopes clear the same table. + override suspend fun deleteNamespace(namespace: StoreNamespace) = dao.deleteAll() + override suspend fun deleteAll() = dao.deleteAll() +} + +class UserRepository(api: UserApi, dao: UserDao) { + @OptIn(ExperimentalStoreApi::class) + private val store = store { + fetcher { key -> api.fetchUser(key.id) } // retries and fallbacks belong inside this block + persistence(UserDaoSourceOfTruth(dao)) + } + + // The Store 5 Validator bounded staleness at 5 minutes; MaxAge puts that bound on the call. + // Difference: age is measured from commit time, not the value's own timestamp field, and an + // over-age resident is withheld until the fetch succeeds rather than emitted alongside it. + fun observeUser(id: String): Flow = + store.stream(UserKey(id), Freshness.MaxAge(5.minutes)) + .map { result -> + when (result) { + is StoreResult.Loading -> UserUiState.Loading + is StoreResult.Data -> UserUiState.Loaded( + user = result.value, + fromCache = result.origin == Origin.MEMORY, + ) + // The conditional fetch confirmed the current value is fresh; nothing new to render. + is StoreResult.Revalidated -> null + is StoreResult.Error -> UserUiState.Failed(result.error.describe()) + } + } + .filterNotNull() + + // Store 5 store.fresh(id): block for a network round trip or fail. + suspend fun refreshUser(id: String): User = store.get(UserKey(id), Freshness.MustBeFresh) + + // Sign-out data is wrong to show afterward, so clear rather than invalidate. + suspend fun onSignOut() = store.clearAll() + + // No builder scope(...): the owner releases the store explicitly. + fun close() = store.close() +} + +private fun StoreError.describe(): String = when (this) { + is StoreError.Fetch -> message + is StoreError.Persistence -> message + is StoreError.Conversion -> message + is StoreError.FreshnessUnsatisfiable -> message + is StoreError.Conflict -> message + is StoreError.Missing -> message +} +``` + +If the screen must refetch on every subscription regardless of age (exact `cached(refresh = true)` behavior), keep the default `stream(key)` and have the refresh action call `invalidate(key)`. There is no single-policy equivalent. diff --git a/renovate.json b/renovate.json index 5db72dd6a..399b7c211 100644 --- a/renovate.json +++ b/renovate.json @@ -2,5 +2,19 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended" + ], + "packageRules": [ + { + "description": "Kotlin-locked (klib ABI): move only with baseKotlin, as one manual coordinated bump.", + "matchPackagePrefixes": [ + "app.cash.sqldelight", + "org.jetbrains.compose", + "org.jetbrains.androidx.lifecycle", + "com.google.devtools.ksp", + "androidx.room3", + "androidx.sqlite" + ], + "enabled": false + } ] } diff --git a/rx2/api/rx2.api b/rx2/api/rx2.api deleted file mode 100644 index 8c2b6797c..000000000 --- a/rx2/api/rx2.api +++ /dev/null @@ -1,26 +0,0 @@ -public final class org/mobilenativefoundation/store/rx2/RxFetcherKt { - public static final fun ofFlowable (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public static final fun ofResultFlowable (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public static final fun ofResultSingle (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public static final fun ofSingle (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; -} - -public final class org/mobilenativefoundation/store/rx2/RxSourceOfTruthKt { - public static final fun ofFlowable (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; - public static synthetic fun ofFlowable$default (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; - public static final fun ofMaybe (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; - public static synthetic fun ofMaybe$default (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; -} - -public final class org/mobilenativefoundation/store/rx2/RxStoreBuilderKt { - public static final fun withScheduler (Lorg/mobilenativefoundation/store/store5/StoreBuilder;Lio/reactivex/Scheduler;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; -} - -public final class org/mobilenativefoundation/store/rx2/RxStoreKt { - public static final fun freshSingle (Lorg/mobilenativefoundation/store/store5/Store;Ljava/lang/Object;)Lio/reactivex/Single; - public static final fun getSingle (Lorg/mobilenativefoundation/store/store5/Store;Ljava/lang/Object;)Lio/reactivex/Single; - public static final fun observe (Lorg/mobilenativefoundation/store/store5/Store;Lorg/mobilenativefoundation/store/store5/StoreReadRequest;)Lio/reactivex/Flowable; - public static final fun observeClear (Lorg/mobilenativefoundation/store/store5/Store;Ljava/lang/Object;)Lio/reactivex/Completable; - public static final fun observeClearAll (Lorg/mobilenativefoundation/store/store5/Store;)Lio/reactivex/Completable; -} - diff --git a/rx2/build.gradle.kts b/rx2/build.gradle.kts deleted file mode 100644 index bf887b115..000000000 --- a/rx2/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -@file:Suppress("UnstableApiUsage") - -plugins { - id("org.mobilenativefoundation.store.android") -} - -dependencies { - implementation(libs.kotlinx.coroutines.rx2) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.coroutines.android) - implementation(libs.rxjava) - implementation(projects.store) - - testImplementation(kotlin("test")) - testImplementation(libs.junit) - testImplementation(libs.google.truth) - testImplementation(libs.androidx.test.core) - testImplementation(libs.kotlinx.coroutines.test) -} - -android { - namespace = "org.mobilenativefoundation.store.rx2" -} diff --git a/rx2/gradle.properties b/rx2/gradle.properties deleted file mode 100644 index dc283f53f..000000000 --- a/rx2/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -POM_NAME=org.mobilenativefoundation.store -POM_ARTIFACT_ID=rx2 -POM_PACKAGING=jar \ No newline at end of file diff --git a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxFetcher.kt b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxFetcher.kt deleted file mode 100644 index 57332ee8e..000000000 --- a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxFetcher.kt +++ /dev/null @@ -1,68 +0,0 @@ -package org.mobilenativefoundation.store.rx2 - -import io.reactivex.Flowable -import io.reactivex.Single -import kotlinx.coroutines.reactive.asFlow -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.Store - -/** - * Creates a new [Fetcher] from a [flowableFactory]. - * - * [Store] does not catch exception thrown in [flowableFactory] or in the returned [Flowable]. These - * exception will be propagated to the caller. - * - * Use when creating a [Store] that fetches objects in a multiple responses per request - * network protocol (e.g Web Sockets). - * - * @param flowableFactory a factory for a [Flowable] source of network records. - */ -fun Fetcher.Companion.ofResultFlowable( - flowableFactory: (key: Key) -> Flowable>, -): Fetcher = ofResultFlow { key: Key -> flowableFactory(key).asFlow() } - -/** - * "Creates" a [Fetcher] from a [singleFactory]. - * - * [Store] does not catch exception thrown in [singleFactory] or in the returned [Single]. These - * exception will be propagated to the caller. - * - * Use when creating a [Store] that fetches objects in a single response per request network - * protocol (e.g Http). - * - * @param singleFactory a factory for a [Single] source of network records. - */ -fun Fetcher.Companion.ofResultSingle( - singleFactory: (key: Key) -> Single>, -): Fetcher = ofResultFlowable { key: Key -> singleFactory(key).toFlowable() } - -/** - * "Creates" a [Fetcher] from a [flowableFactory] and translate the results to a [FetcherResult]. - * - * Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the stream then - * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [flowableFactory] itself are - * not caught and will be returned to the caller. - * - * Use when creating a [Store] that fetches objects in a multiple responses per request - * network protocol (e.g Web Sockets). - * - * @param flowFactory a factory for a [Flowable] source of network records. - */ -fun Fetcher.Companion.ofFlowable(flowableFactory: (key: Key) -> Flowable): Fetcher = - ofFlow { key: Key -> flowableFactory(key).asFlow() } - -/** - * Creates a new [Fetcher] from a [singleFactory] and translate the results to a [FetcherResult]. - * - * The emitted value will be wrapped in [FetcherResult.Data]. if an exception is returned then - * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [singleFactory] itself are - * not caught and will be returned to the caller. - * - * Use when creating a [Store] that fetches objects in a single response per request network - * protocol (e.g Http). - * - * @param singleFactory a factory for a [Single] source of network records. - */ -fun Fetcher.Companion.ofSingle(singleFactory: (key: Key) -> Single): Fetcher = - ofFlowable { key: Key -> singleFactory(key).toFlowable() } diff --git a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxSourceOfTruth.kt b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxSourceOfTruth.kt deleted file mode 100644 index 68e18adc5..000000000 --- a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxSourceOfTruth.kt +++ /dev/null @@ -1,63 +0,0 @@ -package org.mobilenativefoundation.store.rx2 - -import io.reactivex.Completable -import io.reactivex.Flowable -import io.reactivex.Maybe -import kotlinx.coroutines.reactive.asFlow -import kotlinx.coroutines.rx2.await -import kotlinx.coroutines.rx2.awaitSingleOrNull -import org.mobilenativefoundation.store.store5.SourceOfTruth - -/** - * Creates a [Maybe] source of truth that is accessible via [reader], [writer], [delete] and - * [deleteAll]. - * - * @param reader function for reading records from the source of truth - * @param writer function for writing updates to the backing source of truth - * @param delete function for deleting records in the source of truth for the given key - * @param deleteAll function for deleting all records in the source of truth - * - */ -fun SourceOfTruth.Companion.ofMaybe( - reader: (Key) -> Maybe, - writer: (Key, Local) -> Completable, - delete: ((Key) -> Completable)? = null, - deleteAll: (() -> Completable)? = null, -): SourceOfTruth { - val deleteFun: (suspend (Key) -> Unit)? = - if (delete != null) { key -> delete(key).await() } else null - val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } - return of( - nonFlowReader = { key -> reader.invoke(key).awaitSingleOrNull() }, - writer = { key, output -> writer.invoke(key, output).await() }, - delete = deleteFun, - deleteAll = deleteAllFun, - ) -} - -/** - * Creates a ([Flowable]) source of truth that is accessed via [reader], [writer], [delete] and - * [deleteAll]. - * - * @param reader function for reading records from the source of truth - * @param writer function for writing updates to the backing source of truth - * @param delete function for deleting records in the source of truth for the given key - * @param deleteAll function for deleting all records in the source of truth - * - */ -fun SourceOfTruth.Companion.ofFlowable( - reader: (Key) -> Flowable, - writer: (Key, Local) -> Completable, - delete: ((Key) -> Completable)? = null, - deleteAll: (() -> Completable)? = null, -): SourceOfTruth { - val deleteFun: (suspend (Key) -> Unit)? = - if (delete != null) { key -> delete(key).await() } else null - val deleteAllFun: (suspend () -> Unit)? = deleteAll?.let { { deleteAll().await() } } - return of( - reader = { key -> reader.invoke(key).asFlow() }, - writer = { key, output -> writer.invoke(key, output).await() }, - delete = deleteFun, - deleteAll = deleteAllFun, - ) -} diff --git a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStore.kt b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStore.kt deleted file mode 100644 index b411961bb..000000000 --- a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStore.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.mobilenativefoundation.store.rx2 - -import io.reactivex.Completable -import io.reactivex.Flowable -import kotlinx.coroutines.rx2.asFlowable -import kotlinx.coroutines.rx2.rxCompletable -import kotlinx.coroutines.rx2.rxSingle -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreBuilder -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.impl.extensions.fresh -import org.mobilenativefoundation.store.store5.impl.extensions.get - -/** - * Return a [Flowable] for the given key - * @param request - see [StoreReadRequest] for configurations - */ -fun Store.observe(request: StoreReadRequest): Flowable> = - stream(request).asFlowable() - -/** - * Purge a particular entry from memory and disk cache. - * Persistent storage will only be cleared if a delete function was passed to - * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. - */ -fun Store.observeClear(key: Key): Completable = rxCompletable { clear(key) } - -/** - * Purge all entries from memory and disk cache. - * Persistent storage will only be cleared if a deleteAll function was passed to - * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. - */ -@ExperimentalStoreApi -fun Store.observeClearAll(): Completable = rxCompletable { clear() } - -/** - * Helper factory that will return data as a [Single] for [key] if it is cached otherwise will return fresh/network data (updating your caches) - */ -fun Store.getSingle(key: Key) = rxSingle { this@getSingle.get(key) } - -/** - * Helper factory that will return fresh data as a [Single] for [key] while updating your caches - */ -fun Store.freshSingle(key: Key) = rxSingle { this@freshSingle.fresh(key) } diff --git a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStoreBuilder.kt b/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStoreBuilder.kt deleted file mode 100644 index 5d378c3d4..000000000 --- a/rx2/src/main/kotlin/org/mobilenativefoundation/store/rx2/RxStoreBuilder.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.mobilenativefoundation.store.rx2 - -import io.reactivex.Scheduler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.rx2.asCoroutineDispatcher -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreBuilder - -/** - * A store multicasts same [Output] value to many consumers (Similar to RxJava.share()), by default - * [Store] will open a global scope for management of shared responses, if instead you'd like to control - * the scheduler that sharing/multicasting happens in you can pass a @param [scheduler] - * - * Note this does not control what scheduler a response is emitted on but rather what thread/scheduler - * to use when managing in flight responses. This is usually used for things like testing where you - * may want to confine to a scheduler backed by a single thread executor - * - * @param scheduler - scheduler to use for sharing - * if a scheduler is not set Store will use [GlobalScope] - */ -fun StoreBuilder.withScheduler(scheduler: Scheduler): StoreBuilder { - return scope(CoroutineScope(scheduler.asCoroutineDispatcher())) -} diff --git a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/FlowTestExt.kt b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/FlowTestExt.kt deleted file mode 100644 index 791126c41..000000000 --- a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/FlowTestExt.kt +++ /dev/null @@ -1,88 +0,0 @@ -package org.mobilenativefoundation.store.rx2.test - -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.google.common.truth.FailureMetadata -import com.google.common.truth.Subject -import com.google.common.truth.Truth -import com.google.common.truth.Truth.assertWithMessage -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle - -@OptIn(ExperimentalCoroutinesApi::class) -internal fun TestScope.assertThat(flow: Flow): FlowSubject { - return Truth.assertAbout(FlowSubject.Factory(this)).that(flow) -} - -@OptIn(ExperimentalCoroutinesApi::class) -internal class FlowSubject constructor( - failureMetadata: FailureMetadata, - private val testCoroutineScope: TestScope, - private val actual: Flow, -) : Subject(failureMetadata, actual) { - /** - * Takes all items in the flow that are available by collecting on it as long as there are - * active jobs in the given [TestCoroutineScope]. - * - * It ensures all expected items are dispatched as well as no additional unexpected items are - * dispatched. - */ - suspend fun emitsExactly(vararg expected: T) { - val collectedSoFar = mutableListOf() - val collectionCoroutine = - testCoroutineScope.async { - actual.collect { - collectedSoFar.add(it) - if (collectedSoFar.size > expected.size) { - assertWithMessage("Too many emissions in the flow (only first additional item is shown)") - .that(collectedSoFar) - .isEqualTo(expected) - } - } - } - testCoroutineScope.advanceUntilIdle() - if (!collectionCoroutine.isActive) { - collectionCoroutine.getCompletionExceptionOrNull()?.let { - throw it - } - } - collectionCoroutine.cancelAndJoin() - assertWithMessage("Flow didn't exactly emit expected items") - .that(collectedSoFar) - .isEqualTo(expected.toList()) - } - - class Factory( - private val testCoroutineScope: TestScope, - ) : Subject.Factory, Flow> { - override fun createSubject( - metadata: FailureMetadata, - actual: Flow?, - ): FlowSubject { - return FlowSubject( - failureMetadata = metadata, - actual = actual ?: emptyFlow(), - testCoroutineScope = testCoroutineScope, - ) - } - } -} diff --git a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/HotRxSingleStoreTest.kt b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/HotRxSingleStoreTest.kt deleted file mode 100644 index 8ce70f641..000000000 --- a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/HotRxSingleStoreTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -package org.mobilenativefoundation.store.rx2.test - -import com.google.common.truth.Truth.assertThat -import io.reactivex.Single -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 -import org.mobilenativefoundation.store.rx2.ofResultSingle -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.StoreBuilder -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin - -@RunWith(JUnit4::class) -@FlowPreview -@ExperimentalCoroutinesApi -class HotRxSingleStoreTest { - @Test - fun `GIVEN a hot fetcher WHEN two cached and one fresh call THEN fetcher is only called twice`() = - runTest { - val fetcher: FakeRxFetcher> = - FakeRxFetcher( - 3 to FetcherResult.Data("three-1"), - 3 to FetcherResult.Data("three-2"), - ) - val pipeline = - StoreBuilder.from(Fetcher.ofResultSingle { fetcher.fetch(it) }) - .scope(this) - .build() - - assertThat(pipeline.stream(StoreReadRequest.cached(3, refresh = false))) - .emitsExactly( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ) - assertThat( - pipeline.stream(StoreReadRequest.cached(3, refresh = false)), - ).emitsExactly( - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ) - - assertThat(pipeline.stream(StoreReadRequest.fresh(3))) - .emitsExactly( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ) - } -} - -class FakeRxFetcher( - vararg val responses: Pair, -) { - private var index = 0 - - @Suppress("RedundantSuspendModifier") // needed for function reference - fun fetch(key: Key): Single { - // will throw if fetcher called more than twice - if (index >= responses.size) { - throw AssertionError("unexpected fetch request") - } - val pair = responses[index++] - assertThat(pair.first).isEqualTo(key) - return Single.just(pair.second) - } -} diff --git a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxFlowableStoreTest.kt b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxFlowableStoreTest.kt deleted file mode 100644 index d7ec5d998..000000000 --- a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxFlowableStoreTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -package org.mobilenativefoundation.store.rx2.test - -import io.reactivex.BackpressureStrategy -import io.reactivex.Completable -import io.reactivex.Flowable -import io.reactivex.schedulers.TestScheduler -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 -import org.mobilenativefoundation.store.rx2.observe -import org.mobilenativefoundation.store.rx2.ofFlowable -import org.mobilenativefoundation.store.rx2.ofResultFlowable -import org.mobilenativefoundation.store.rx2.withScheduler -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.StoreBuilder -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin -import java.util.concurrent.atomic.AtomicInteger - -@RunWith(JUnit4::class) -@FlowPreview -@ExperimentalCoroutinesApi -class RxFlowableStoreTest { - private val testScheduler = TestScheduler() - private val atomicInteger = AtomicInteger(0) - private val fakeDisk = mutableMapOf() - private val store = - StoreBuilder.from( - fetcher = - Fetcher.ofResultFlowable { - Flowable.create( - { emitter -> - emitter.onNext( - FetcherResult.Data("$it ${atomicInteger.incrementAndGet()} occurrence"), - ) - emitter.onNext( - FetcherResult.Data("$it ${atomicInteger.incrementAndGet()} occurrence"), - ) - emitter.onComplete() - }, - BackpressureStrategy.BUFFER, - ) - }, - sourceOfTruth = - SourceOfTruth.ofFlowable( - reader = { - if (fakeDisk[it] != null) { - Flowable.fromCallable { fakeDisk[it]!! } - } else { - Flowable.empty() - } - }, - writer = { key, value -> - Completable.fromAction { fakeDisk[key] = value } - }, - ), - ) - .withScheduler(testScheduler) - .build() - - @Test - fun simpleTest() { - val testSubscriber1 = - store.observe(StoreReadRequest.fresh(3)) - .subscribeOn(testScheduler) - .test() - testScheduler.triggerActions() - testSubscriber1 - .awaitCount(3) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 1 occurrence", StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 2 occurrence", StoreReadResponseOrigin.Fetcher()), - ) - - val testSubscriber2 = - store.observe(StoreReadRequest.cached(3, false)) - .subscribeOn(testScheduler) - .test() - testScheduler.triggerActions() - testSubscriber2 - .awaitCount(2) - .assertValues( - StoreReadResponse.Data("3 2 occurrence", StoreReadResponseOrigin.Cache), - StoreReadResponse.Data("3 2 occurrence", StoreReadResponseOrigin.SourceOfTruth), - ) - - val testSubscriber3 = - store.observe(StoreReadRequest.fresh(3)) - .subscribeOn(testScheduler) - .test() - testScheduler.triggerActions() - testSubscriber3 - .awaitCount(3) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 3 occurrence", StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 4 occurrence", StoreReadResponseOrigin.Fetcher()), - ) - - val testSubscriber4 = - store.observe(StoreReadRequest.cached(3, false)) - .subscribeOn(testScheduler) - .test() - testScheduler.triggerActions() - testSubscriber4 - .awaitCount(2) - .assertValues( - StoreReadResponse.Data("3 4 occurrence", StoreReadResponseOrigin.Cache), - StoreReadResponse.Data("3 4 occurrence", StoreReadResponseOrigin.SourceOfTruth), - ) - } -} diff --git a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreExtensionsTest.kt b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreExtensionsTest.kt deleted file mode 100644 index 6ddee0df1..000000000 --- a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreExtensionsTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package org.mobilenativefoundation.store.rx2.test - -import io.reactivex.Completable -import io.reactivex.Maybe -import io.reactivex.Single -import io.reactivex.schedulers.Schedulers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.rx2.freshSingle -import org.mobilenativefoundation.store.rx2.getSingle -import org.mobilenativefoundation.store.rx2.ofMaybe -import org.mobilenativefoundation.store.rx2.ofResultSingle -import org.mobilenativefoundation.store.rx2.withScheduler -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.StoreBuilder -import java.util.concurrent.atomic.AtomicInteger - -@ExperimentalStoreApi -@RunWith(JUnit4::class) -@FlowPreview -@ExperimentalCoroutinesApi -class RxSingleStoreExtensionsTest { - private val atomicInteger = AtomicInteger(0) - private var fakeDisk = mutableMapOf() - private val store = - StoreBuilder.from( - fetcher = - Fetcher.ofResultSingle { - Single.fromCallable { FetcherResult.Data("$it ${atomicInteger.incrementAndGet()}") } - }, - sourceOfTruth = - SourceOfTruth.ofMaybe( - reader = { Maybe.fromCallable { fakeDisk[it] } }, - writer = { key, value -> - Completable.fromAction { fakeDisk[key] = value } - }, - delete = { key -> - Completable.fromAction { fakeDisk.remove(key) } - }, - deleteAll = { - Completable.fromAction { fakeDisk.clear() } - }, - ), - ) - .withScheduler(Schedulers.trampoline()) - .build() - - @Test - fun `store rx extension tests`() { - // Return from cache - after initial fetch - store.getSingle(3) - .test() - .await() - .assertValue("3 1") - - // Return from cache - store.getSingle(3) - .test() - .await() - .assertValue("3 1") - - // Return from fresh - forcing a new fetch - store.freshSingle(3) - .test() - .await() - .assertValue("3 2") - - // Return from cache - different to initial - store.getSingle(3) - .test() - .await() - .assertValue("3 2") - } -} diff --git a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreTest.kt b/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreTest.kt deleted file mode 100644 index 6276514de..000000000 --- a/rx2/src/test/kotlin/org/mobilenativefoundation/store/rx2/test/RxSingleStoreTest.kt +++ /dev/null @@ -1,131 +0,0 @@ -package org.mobilenativefoundation.store.rx2.test - -import io.reactivex.Completable -import io.reactivex.Maybe -import io.reactivex.Single -import io.reactivex.schedulers.Schedulers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.JUnit4 -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.rx2.observe -import org.mobilenativefoundation.store.rx2.observeClear -import org.mobilenativefoundation.store.rx2.observeClearAll -import org.mobilenativefoundation.store.rx2.ofMaybe -import org.mobilenativefoundation.store.rx2.ofResultSingle -import org.mobilenativefoundation.store.rx2.withScheduler -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.StoreBuilder -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin -import java.util.concurrent.atomic.AtomicInteger - -@ExperimentalStoreApi -@RunWith(JUnit4::class) -@FlowPreview -@ExperimentalCoroutinesApi -class RxSingleStoreTest { - private val atomicInteger = AtomicInteger(0) - private var fakeDisk = mutableMapOf() - private val store = - StoreBuilder.from( - fetcher = - Fetcher.ofResultSingle { - Single.fromCallable { FetcherResult.Data("$it ${atomicInteger.incrementAndGet()}") } - }, - sourceOfTruth = - SourceOfTruth.ofMaybe( - reader = { Maybe.fromCallable { fakeDisk[it] } }, - writer = { key, value -> - Completable.fromAction { fakeDisk[key] = value } - }, - delete = { key -> - Completable.fromAction { fakeDisk.remove(key) } - }, - deleteAll = { - Completable.fromAction { fakeDisk.clear() } - }, - ), - ) - .withScheduler(Schedulers.trampoline()) - .build() - - @Test - fun simpleTest() { - store.observe(StoreReadRequest.cached(3, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Fetcher()), - ) - - store.observe(StoreReadRequest.cached(3, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Cache), - StoreReadResponse.Data("3 1", StoreReadResponseOrigin.SourceOfTruth), - ) - - store.observe(StoreReadRequest.fresh(3)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 2", StoreReadResponseOrigin.Fetcher()), - ) - - store.observe(StoreReadRequest.cached(3, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Data("3 2", StoreReadResponseOrigin.Cache), - StoreReadResponse.Data("3 2", StoreReadResponseOrigin.SourceOfTruth), - ) - } - - @Test - fun `GIVEN a store with persister values WHEN observeClear is Called THEN next Store get hits network`() { - fakeDisk[3] = "seeded occurrence" - - store.observeClear(3).blockingGet() - - store.observe(StoreReadRequest.cached(3, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Fetcher()), - ) - } - - @Test - fun `GIVEN a store with persister values WHEN observeClearAll is called THEN next Store get calls both hit network`() { - fakeDisk[3] = "seeded occurrence" - fakeDisk[4] = "another seeded occurrence" - - store.observeClearAll().blockingGet() - - store.observe(StoreReadRequest.cached(3, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("3 1", StoreReadResponseOrigin.Fetcher()), - ) - - store.observe(StoreReadRequest.cached(4, false)) - .test() - .awaitCount(2) - .assertValues( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data("4 2", StoreReadResponseOrigin.Fetcher()), - ) - } -} diff --git a/settings.gradle b/settings.gradle index 2ff947f5a..1d5b749c1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,29 +1,54 @@ pluginManagement { includeBuild("tooling") repositories { - mavenCentral() gradlePluginPortal() + mavenCentral() google() } } plugins { - id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" } enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") -dependencyResolutionManagement { - repositories { - mavenCentral() - google() - } -} - rootProject.name = "Store5" -include ':store' -include ':cache' -include ':multicast' -include ':rx2' -include ':core' +include ':store6-core' +include ':store6-quickstart' +include ':store6-swift-dumps-objc' +project(':store6-swift-dumps-objc').projectDir = file('store6-swift-dumps/objc') +include ':store6-swift-dumps-skie' +project(':store6-swift-dumps-skie').projectDir = file('store6-swift-dumps/skie') +include ':store6-swift-dumps-mutations-objc' +project(':store6-swift-dumps-mutations-objc').projectDir = file('store6-swift-dumps/mutations-objc') +include ':store6-swift-dumps-mutations-skie' +project(':store6-swift-dumps-mutations-skie').projectDir = file('store6-swift-dumps/mutations-skie') +include ':store6-extension-probe' +include ':store6-testing' +include ':store6-sqldelight' +include ':store6-sqldelight-sample' +project(':store6-sqldelight-sample').projectDir = file('store6-sqldelight/sample') +include ':store6-compose' +include ':store6-compose-demo' +include ':store6-room' +include ':store6-room-sample' +project(':store6-room-sample').projectDir = file('store6-room/sample') +include ':store6-paging-androidx' +include ':store6-paging-androidx-sample' +project(':store6-paging-androidx-sample').projectDir = file('store6-paging-androidx/sample') +include ':store6-graphql' +include ':store6-graphql-sample' +project(':store6-graphql-sample').projectDir = file('store6-graphql/sample') +include ':store6-realtime' +include ':store6-realtime-sample' +project(':store6-realtime-sample').projectDir = file('store6-realtime/sample') +include ':store6-benchmarks' +include ':store6-devtools' +include ':store6-devtools-inspector' +include ':store6-devtools-demo' +include ':store6-mutations' +include ':store6-mutations-quickstart' +include ':store6-mutations-testing' +include ':store6-mutations-sqldelight' diff --git a/store/api/jvm/store.api b/store/api/jvm/store.api deleted file mode 100644 index d15cdd9d7..000000000 --- a/store/api/jvm/store.api +++ /dev/null @@ -1,697 +0,0 @@ -public abstract interface class org/mobilenativefoundation/store/store5/Bookkeeper { - public static final field Companion Lorg/mobilenativefoundation/store/store5/Bookkeeper$Companion; - public abstract fun clear (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun clearAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun getLastFailedSync (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun setLastFailedSync (Ljava/lang/Object;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun setLastFailedSync$default (Lorg/mobilenativefoundation/store/store5/Bookkeeper;Ljava/lang/Object;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; -} - -public final class org/mobilenativefoundation/store/store5/Bookkeeper$Companion { - public final fun by (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Bookkeeper; -} - -public final class org/mobilenativefoundation/store/store5/Bookkeeper$DefaultImpls { - public static synthetic fun setLastFailedSync$default (Lorg/mobilenativefoundation/store/store5/Bookkeeper;Ljava/lang/Object;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Clear { -} - -public abstract interface class org/mobilenativefoundation/store/store5/Clear$All { - public abstract fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Clear$Key { - public abstract fun clear (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Converter { - public abstract fun fromNetworkToLocal (Ljava/lang/Object;)Ljava/lang/Object; - public abstract fun fromOutputToLocal (Ljava/lang/Object;)Ljava/lang/Object; -} - -public final class org/mobilenativefoundation/store/store5/Converter$Builder { - public field fromNetworkToLocal Lkotlin/jvm/functions/Function1; - public field fromOutputToLocal Lkotlin/jvm/functions/Function1; - public fun ()V - public final fun build ()Lorg/mobilenativefoundation/store/store5/Converter; - public final fun fromNetworkToLocal (Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Converter$Builder; - public final fun fromOutputToLocal (Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Converter$Builder; - public final fun getFromNetworkToLocal ()Lkotlin/jvm/functions/Function1; - public final fun getFromOutputToLocal ()Lkotlin/jvm/functions/Function1; - public final fun setFromNetworkToLocal (Lkotlin/jvm/functions/Function1;)V - public final fun setFromOutputToLocal (Lkotlin/jvm/functions/Function1;)V -} - -public abstract interface class org/mobilenativefoundation/store/store5/Fetcher { - public static final field Companion Lorg/mobilenativefoundation/store/store5/Fetcher$Companion; - public abstract fun getFallback ()Lorg/mobilenativefoundation/store/store5/Fetcher; - public abstract fun getName ()Ljava/lang/String; - public abstract fun invoke (Ljava/lang/Object;)Lkotlinx/coroutines/flow/Flow; -} - -public final class org/mobilenativefoundation/store/store5/Fetcher$Companion { - public final fun of (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public static synthetic fun of$default (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public final fun ofFlow (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public static synthetic fun ofFlow$default (Lorg/mobilenativefoundation/store/store5/Fetcher$Companion;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public final fun ofFlowWithFallback (Ljava/lang/String;Lorg/mobilenativefoundation/store/store5/Fetcher;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public final fun ofResult (Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public final fun ofResultFlow (Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public final fun ofResultFlowWithFallback (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lorg/mobilenativefoundation/store/store5/Fetcher;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public final fun ofResultWithFallback (Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lorg/mobilenativefoundation/store/store5/Fetcher;)Lorg/mobilenativefoundation/store/store5/Fetcher; - public final fun withFallback (Ljava/lang/String;Lorg/mobilenativefoundation/store/store5/Fetcher;Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store/store5/Fetcher; -} - -public abstract class org/mobilenativefoundation/store/store5/FetcherResult { -} - -public final class org/mobilenativefoundation/store/store5/FetcherResult$Data : org/mobilenativefoundation/store/store5/FetcherResult { - public fun (Ljava/lang/Object;Ljava/lang/String;)V - public synthetic fun (Ljava/lang/Object;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/Object; - public final fun component2 ()Ljava/lang/String; - public final fun copy (Ljava/lang/Object;Ljava/lang/String;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Data; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/FetcherResult$Data;Ljava/lang/Object;Ljava/lang/String;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Data; - public fun equals (Ljava/lang/Object;)Z - public final fun getOrigin ()Ljava/lang/String; - public final fun getValue ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract class org/mobilenativefoundation/store/store5/FetcherResult$Error : org/mobilenativefoundation/store/store5/FetcherResult { -} - -public final class org/mobilenativefoundation/store/store5/FetcherResult$Error$Custom : org/mobilenativefoundation/store/store5/FetcherResult$Error { - public fun (Ljava/lang/Object;)V - public final fun component1 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/Object;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Custom; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Custom;Ljava/lang/Object;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Custom; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/FetcherResult$Error$Exception : org/mobilenativefoundation/store/store5/FetcherResult$Error { - public fun (Ljava/lang/Throwable;)V - public final fun component1 ()Ljava/lang/Throwable; - public final fun copy (Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Exception; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Exception;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Exception; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Throwable; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/FetcherResult$Error$Message : org/mobilenativefoundation/store/store5/FetcherResult$Error { - public fun (Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Message; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Message;Ljava/lang/String;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/FetcherResult$Error$Message; - public fun equals (Ljava/lang/Object;)Z - public final fun getMessage ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Logger { - public abstract fun debug (Ljava/lang/String;)V - public abstract fun error (Ljava/lang/String;Ljava/lang/Throwable;)V - public static synthetic fun error$default (Lorg/mobilenativefoundation/store/store5/Logger;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V -} - -public final class org/mobilenativefoundation/store/store5/Logger$DefaultImpls { - public static synthetic fun error$default (Lorg/mobilenativefoundation/store/store5/Logger;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V -} - -public final class org/mobilenativefoundation/store/store5/MemoryPolicy { - public static final field Companion Lorg/mobilenativefoundation/store/store5/MemoryPolicy$Companion; - public static final field DEFAULT_SIZE_POLICY J - public final fun getExpireAfterAccess-UwyO8pc ()J - public final fun getExpireAfterWrite-UwyO8pc ()J - public final fun getHasAccessPolicy ()Z - public final fun getHasMaxSize ()Z - public final fun getHasMaxWeight ()Z - public final fun getHasWritePolicy ()Z - public final fun getMaxSize ()J - public final fun getMaxWeight ()J - public final fun getWeigher ()Lorg/mobilenativefoundation/store/store5/Weigher; - public final fun isDefaultWritePolicy ()Z -} - -public final class org/mobilenativefoundation/store/store5/MemoryPolicy$Companion { - public final fun builder ()Lorg/mobilenativefoundation/store/store5/MemoryPolicy$MemoryPolicyBuilder; - public final fun getDEFAULT_DURATION_POLICY-UwyO8pc ()J -} - -public final class org/mobilenativefoundation/store/store5/MemoryPolicy$MemoryPolicyBuilder { - public fun ()V - public final fun build ()Lorg/mobilenativefoundation/store/store5/MemoryPolicy; - public final fun setExpireAfterAccess-LRDsOJo (J)Lorg/mobilenativefoundation/store/store5/MemoryPolicy$MemoryPolicyBuilder; - public final fun setExpireAfterWrite-LRDsOJo (J)Lorg/mobilenativefoundation/store/store5/MemoryPolicy$MemoryPolicyBuilder; - public final fun setMaxSize (J)Lorg/mobilenativefoundation/store/store5/MemoryPolicy$MemoryPolicyBuilder; - public final fun setWeigherAndMaxWeight (Lorg/mobilenativefoundation/store/store5/Weigher;J)Lorg/mobilenativefoundation/store/store5/MemoryPolicy$MemoryPolicyBuilder; -} - -public abstract interface class org/mobilenativefoundation/store/store5/MutableStore : org/mobilenativefoundation/store/store5/Clear, org/mobilenativefoundation/store/store5/Clear$Key, org/mobilenativefoundation/store/store5/Read$StreamWithConflictResolution, org/mobilenativefoundation/store/store5/Write, org/mobilenativefoundation/store/store5/Write$Stream { -} - -public abstract interface class org/mobilenativefoundation/store/store5/MutableStoreBuilder { - public static final field Companion Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder$Companion; - public abstract fun build (Lorg/mobilenativefoundation/store/store5/Updater;Lorg/mobilenativefoundation/store/store5/Bookkeeper;)Lorg/mobilenativefoundation/store/store5/MutableStore; - public static synthetic fun build$default (Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder;Lorg/mobilenativefoundation/store/store5/Updater;Lorg/mobilenativefoundation/store/store5/Bookkeeper;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/MutableStore; - public abstract fun cachePolicy (Lorg/mobilenativefoundation/store/store5/MemoryPolicy;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; - public abstract fun disableCache ()Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; - public abstract fun scope (Lkotlinx/coroutines/CoroutineScope;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; - public abstract fun validator (Lorg/mobilenativefoundation/store/store5/Validator;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; -} - -public final class org/mobilenativefoundation/store/store5/MutableStoreBuilder$Companion { - public final fun from (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; -} - -public final class org/mobilenativefoundation/store/store5/MutableStoreBuilder$DefaultImpls { - public static synthetic fun build$default (Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder;Lorg/mobilenativefoundation/store/store5/Updater;Lorg/mobilenativefoundation/store/store5/Bookkeeper;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/MutableStore; -} - -public final class org/mobilenativefoundation/store/store5/OnFetcherCompletion { - public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V - public final fun component1 ()Lkotlin/jvm/functions/Function1; - public final fun component2 ()Lkotlin/jvm/functions/Function1; - public final fun copy (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/OnFetcherCompletion; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/OnFetcherCompletion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/OnFetcherCompletion; - public fun equals (Ljava/lang/Object;)Z - public final fun getOnFailure ()Lkotlin/jvm/functions/Function1; - public final fun getOnSuccess ()Lkotlin/jvm/functions/Function1; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/OnUpdaterCompletion { - public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V - public final fun component1 ()Lkotlin/jvm/functions/Function1; - public final fun component2 ()Lkotlin/jvm/functions/Function1; - public final fun copy (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/OnUpdaterCompletion; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/OnUpdaterCompletion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/OnUpdaterCompletion; - public fun equals (Ljava/lang/Object;)Z - public final fun getOnFailure ()Lkotlin/jvm/functions/Function1; - public final fun getOnSuccess ()Lkotlin/jvm/functions/Function1; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Read { -} - -public abstract interface class org/mobilenativefoundation/store/store5/Read$Stream { - public abstract fun stream (Lorg/mobilenativefoundation/store/store5/StoreReadRequest;)Lkotlinx/coroutines/flow/Flow; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Read$StreamWithConflictResolution { - public abstract fun stream (Lorg/mobilenativefoundation/store/store5/StoreReadRequest;)Lkotlinx/coroutines/flow/Flow; -} - -public abstract interface class org/mobilenativefoundation/store/store5/SourceOfTruth { - public static final field Companion Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion; - public abstract fun delete (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun reader (Ljava/lang/Object;)Lkotlinx/coroutines/flow/Flow; - public abstract fun write (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public final class org/mobilenativefoundation/store/store5/SourceOfTruth$Companion { - public final fun of (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; - public static synthetic fun of$default (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; - public final fun ofFlow (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; - public static synthetic fun ofFlow$default (Lorg/mobilenativefoundation/store/store5/SourceOfTruth$Companion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/SourceOfTruth; -} - -public final class org/mobilenativefoundation/store/store5/SourceOfTruth$ReadException : java/lang/RuntimeException { - public fun (Ljava/lang/Object;Ljava/lang/Throwable;)V - public fun equals (Ljava/lang/Object;)Z - public final fun getKey ()Ljava/lang/Object; - public fun hashCode ()I -} - -public final class org/mobilenativefoundation/store/store5/SourceOfTruth$WriteException : java/lang/RuntimeException { - public fun (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Throwable;)V - public fun equals (Ljava/lang/Object;)Z - public final fun getKey ()Ljava/lang/Object; - public final fun getValue ()Ljava/lang/Object; - public fun hashCode ()I -} - -public abstract interface class org/mobilenativefoundation/store/store5/Store : org/mobilenativefoundation/store/store5/Clear$All, org/mobilenativefoundation/store/store5/Clear$Key, org/mobilenativefoundation/store/store5/Read$Stream { -} - -public abstract interface class org/mobilenativefoundation/store/store5/StoreBuilder { - public static final field Companion Lorg/mobilenativefoundation/store/store5/StoreBuilder$Companion; - public abstract fun build ()Lorg/mobilenativefoundation/store/store5/Store; - public abstract fun cachePolicy (Lorg/mobilenativefoundation/store/store5/MemoryPolicy;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public abstract fun disableCache ()Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public abstract fun scope (Lkotlinx/coroutines/CoroutineScope;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public abstract fun toMutableStoreBuilder (Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; - public abstract fun validator (Lorg/mobilenativefoundation/store/store5/Validator;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; -} - -public final class org/mobilenativefoundation/store/store5/StoreBuilder$Companion { - public final fun from (Lorg/mobilenativefoundation/store/store5/Fetcher;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public final fun from (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public final fun from (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/cache5/Cache;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public final fun from (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public final fun from (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadRequest { - public static final field Companion Lorg/mobilenativefoundation/store/store5/StoreReadRequest$Companion; - public final fun component1 ()Ljava/lang/Object; - public final fun component3 ()Z - public final fun component4 ()Z - public final fun component5 ()Z - public final fun copy (Ljava/lang/Object;IZZZ)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadRequest;Ljava/lang/Object;IZZZILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; - public fun equals (Ljava/lang/Object;)Z - public final fun getFallBackToSourceOfTruth ()Z - public final fun getFetch ()Z - public final fun getKey ()Ljava/lang/Object; - public final fun getRefresh ()Z - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadRequest$Companion { - public final fun cached (Ljava/lang/Object;Z)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; - public final fun fresh (Ljava/lang/Object;Z)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; - public static synthetic fun fresh$default (Lorg/mobilenativefoundation/store/store5/StoreReadRequest$Companion;Ljava/lang/Object;ZILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; - public final fun freshWithFallBackToSourceOfTruth (Ljava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; - public final fun localOnly (Ljava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; - public final fun skipMemory (Ljava/lang/Object;Z)Lorg/mobilenativefoundation/store/store5/StoreReadRequest; -} - -public abstract class org/mobilenativefoundation/store/store5/StoreReadResponse { - public final fun dataOrNull ()Ljava/lang/Object; - public final fun errorMessageOrNull ()Ljava/lang/String; - public final fun errorOrNull ()Ljava/lang/Object; - public abstract fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun requireData ()Ljava/lang/Object; - public final fun throwIfError ()V -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponse$Data : org/mobilenativefoundation/store/store5/StoreReadResponse { - public fun (Ljava/lang/Object;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)V - public final fun component1 ()Ljava/lang/Object; - public final fun component2 ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun copy (Ljava/lang/Object;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Data; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Data;Ljava/lang/Object;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Data; - public fun equals (Ljava/lang/Object;)Z - public fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun getValue ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract class org/mobilenativefoundation/store/store5/StoreReadResponse$Error : org/mobilenativefoundation/store/store5/StoreReadResponse { -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponse$Error$Custom : org/mobilenativefoundation/store/store5/StoreReadResponse$Error { - public fun (Ljava/lang/Object;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)V - public final fun component1 ()Ljava/lang/Object; - public final fun component2 ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun copy (Ljava/lang/Object;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Custom; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Custom;Ljava/lang/Object;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Custom; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Object; - public fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponse$Error$Exception : org/mobilenativefoundation/store/store5/StoreReadResponse$Error { - public fun (Ljava/lang/Throwable;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)V - public final fun component1 ()Ljava/lang/Throwable; - public final fun component2 ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun copy (Ljava/lang/Throwable;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Exception; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Exception;Ljava/lang/Throwable;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Exception; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Throwable; - public fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponse$Error$Message : org/mobilenativefoundation/store/store5/StoreReadResponse$Error { - public fun (Ljava/lang/String;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun copy (Ljava/lang/String;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Message; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Message;Ljava/lang/String;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error$Message; - public fun equals (Ljava/lang/Object;)Z - public final fun getMessage ()Ljava/lang/String; - public fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponse$Initial : org/mobilenativefoundation/store/store5/StoreReadResponse { - public static final field INSTANCE Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Initial; - public fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponse$Loading : org/mobilenativefoundation/store/store5/StoreReadResponse { - public fun (Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)V - public final fun component1 ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun copy (Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Loading; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Loading;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Loading; - public fun equals (Ljava/lang/Object;)Z - public fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponse$NoNewData : org/mobilenativefoundation/store/store5/StoreReadResponse { - public fun (Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)V - public final fun component1 ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public final fun copy (Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$NoNewData; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadResponse$NoNewData;Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadResponse$NoNewData; - public fun equals (Ljava/lang/Object;)Z - public fun getOrigin ()Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponseKt { - public static final fun doThrow (Lorg/mobilenativefoundation/store/store5/StoreReadResponse$Error;)Ljava/lang/Throwable; -} - -public abstract class org/mobilenativefoundation/store/store5/StoreReadResponseOrigin { -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Cache : org/mobilenativefoundation/store/store5/StoreReadResponseOrigin { - public static final field INSTANCE Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Cache; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Fetcher : org/mobilenativefoundation/store/store5/StoreReadResponseOrigin { - public fun ()V - public fun (Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Fetcher; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Fetcher;Ljava/lang/String;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Fetcher; - public fun equals (Ljava/lang/Object;)Z - public final fun getName ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Initial : org/mobilenativefoundation/store/store5/StoreReadResponseOrigin { - public static final field INSTANCE Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin$Initial; -} - -public final class org/mobilenativefoundation/store/store5/StoreReadResponseOrigin$SourceOfTruth : org/mobilenativefoundation/store/store5/StoreReadResponseOrigin { - public static final field INSTANCE Lorg/mobilenativefoundation/store/store5/StoreReadResponseOrigin$SourceOfTruth; -} - -public abstract interface class org/mobilenativefoundation/store/store5/StoreWriteRequest { - public static final field Companion Lorg/mobilenativefoundation/store/store5/StoreWriteRequest$Companion; - public abstract fun getCreated ()J - public abstract fun getKey ()Ljava/lang/Object; - public abstract fun getOnCompletions ()Ljava/util/List; - public abstract fun getValue ()Ljava/lang/Object; -} - -public final class org/mobilenativefoundation/store/store5/StoreWriteRequest$Companion { - public final fun of (Ljava/lang/Object;Ljava/lang/Object;Ljava/util/List;J)Lorg/mobilenativefoundation/store/store5/StoreWriteRequest; - public static synthetic fun of$default (Lorg/mobilenativefoundation/store/store5/StoreWriteRequest$Companion;Ljava/lang/Object;Ljava/lang/Object;Ljava/util/List;JILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreWriteRequest; -} - -public abstract class org/mobilenativefoundation/store/store5/StoreWriteResponse { -} - -public abstract class org/mobilenativefoundation/store/store5/StoreWriteResponse$Error : org/mobilenativefoundation/store/store5/StoreWriteResponse { -} - -public final class org/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Exception : org/mobilenativefoundation/store/store5/StoreWriteResponse$Error { - public fun (Ljava/lang/Throwable;)V - public final fun component1 ()Ljava/lang/Throwable; - public final fun copy (Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Exception; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Exception;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Exception; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Throwable; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Message : org/mobilenativefoundation/store/store5/StoreWriteResponse$Error { - public fun (Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Message; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Message;Ljava/lang/String;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Error$Message; - public fun equals (Ljava/lang/Object;)Z - public final fun getMessage ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract class org/mobilenativefoundation/store/store5/StoreWriteResponse$Success : org/mobilenativefoundation/store/store5/StoreWriteResponse { -} - -public final class org/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Typed : org/mobilenativefoundation/store/store5/StoreWriteResponse$Success { - public fun (Ljava/lang/Object;)V - public final fun component1 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Typed; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Typed;Ljava/lang/Object;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Typed; - public fun equals (Ljava/lang/Object;)Z - public final fun getValue ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Untyped : org/mobilenativefoundation/store/store5/StoreWriteResponse$Success { - public fun (Ljava/lang/Object;)V - public final fun component1 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Untyped; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Untyped;Ljava/lang/Object;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreWriteResponse$Success$Untyped; - public fun equals (Ljava/lang/Object;)Z - public final fun getValue ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Updater { - public static final field Companion Lorg/mobilenativefoundation/store/store5/Updater$Companion; - public abstract fun getOnCompletion ()Lorg/mobilenativefoundation/store/store5/OnUpdaterCompletion; - public abstract fun post (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public final class org/mobilenativefoundation/store/store5/Updater$Companion { - public final fun by (Lkotlin/jvm/functions/Function3;Lorg/mobilenativefoundation/store/store5/OnUpdaterCompletion;)Lorg/mobilenativefoundation/store/store5/Updater; - public static synthetic fun by$default (Lorg/mobilenativefoundation/store/store5/Updater$Companion;Lkotlin/jvm/functions/Function3;Lorg/mobilenativefoundation/store/store5/OnUpdaterCompletion;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/Updater; -} - -public abstract class org/mobilenativefoundation/store/store5/UpdaterResult { -} - -public abstract class org/mobilenativefoundation/store/store5/UpdaterResult$Error : org/mobilenativefoundation/store/store5/UpdaterResult { -} - -public final class org/mobilenativefoundation/store/store5/UpdaterResult$Error$Exception : org/mobilenativefoundation/store/store5/UpdaterResult$Error { - public fun (Ljava/lang/Throwable;)V - public final fun component1 ()Ljava/lang/Throwable; - public final fun copy (Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Error$Exception; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/UpdaterResult$Error$Exception;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Error$Exception; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Throwable; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/UpdaterResult$Error$Message : org/mobilenativefoundation/store/store5/UpdaterResult$Error { - public fun (Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Error$Message; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/UpdaterResult$Error$Message;Ljava/lang/String;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Error$Message; - public fun equals (Ljava/lang/Object;)Z - public final fun getMessage ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract class org/mobilenativefoundation/store/store5/UpdaterResult$Success : org/mobilenativefoundation/store/store5/UpdaterResult { -} - -public final class org/mobilenativefoundation/store/store5/UpdaterResult$Success$Typed : org/mobilenativefoundation/store/store5/UpdaterResult$Success { - public fun (Ljava/lang/Object;)V - public final fun component1 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/Object;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success$Typed; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success$Typed;Ljava/lang/Object;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success$Typed; - public fun equals (Ljava/lang/Object;)Z - public final fun getValue ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/UpdaterResult$Success$Untyped : org/mobilenativefoundation/store/store5/UpdaterResult$Success { - public fun (Ljava/lang/Object;)V - public final fun component1 ()Ljava/lang/Object; - public final fun copy (Ljava/lang/Object;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success$Untyped; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success$Untyped;Ljava/lang/Object;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success$Untyped; - public fun equals (Ljava/lang/Object;)Z - public final fun getValue ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Validator { - public static final field Companion Lorg/mobilenativefoundation/store/store5/Validator$Companion; - public abstract fun isValid (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public final class org/mobilenativefoundation/store/store5/Validator$Companion { - public final fun by (Lkotlin/jvm/functions/Function2;)Lorg/mobilenativefoundation/store/store5/Validator; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Weigher { - public abstract fun weigh (Ljava/lang/Object;Ljava/lang/Object;)I -} - -public abstract interface class org/mobilenativefoundation/store/store5/Write { - public abstract fun write (Lorg/mobilenativefoundation/store/store5/StoreWriteRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public abstract interface class org/mobilenativefoundation/store/store5/Write$Stream { - public abstract fun stream (Lkotlinx/coroutines/flow/Flow;)Lkotlinx/coroutines/flow/Flow; -} - -public final class org/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion { - public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V - public final fun component1 ()Lkotlin/jvm/functions/Function1; - public final fun component2 ()Lkotlin/jvm/functions/Function1; - public final fun copy (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion; - public fun equals (Ljava/lang/Object;)Z - public final fun getOnFailure ()Lkotlin/jvm/functions/Function1; - public final fun getOnSuccess ()Lkotlin/jvm/functions/Function1; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilderKt { - public static final fun mutableStoreBuilderFromFetcher (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; - public static final fun mutableStoreBuilderFromFetcherAndSourceOfTruth (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; - public static final fun mutableStoreBuilderFromFetcherSourceOfTruthAndMemoryCache (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/MutableStoreBuilder; -} - -public final class org/mobilenativefoundation/store/store5/impl/RealStoreBuilderKt { - public static final fun storeBuilderFromFetcher (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public static synthetic fun storeBuilderFromFetcher$default (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public static final fun storeBuilderFromFetcherAndSourceOfTruth (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public static final fun storeBuilderFromFetcherSourceOfTruthAndMemoryCache (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/cache5/Cache;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; - public static final fun storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter (Lorg/mobilenativefoundation/store/store5/Fetcher;Lorg/mobilenativefoundation/store/store5/SourceOfTruth;Lorg/mobilenativefoundation/store/cache5/Cache;Lorg/mobilenativefoundation/store/store5/Converter;)Lorg/mobilenativefoundation/store/store5/StoreBuilder; -} - -public final class org/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest : org/mobilenativefoundation/store/store5/StoreWriteRequest { - public fun (Ljava/lang/Object;Ljava/lang/Object;JLjava/util/List;)V - public final fun component1 ()Ljava/lang/Object; - public final fun component2 ()Ljava/lang/Object; - public final fun component3 ()J - public final fun component4 ()Ljava/util/List; - public final fun copy (Ljava/lang/Object;Ljava/lang/Object;JLjava/util/List;)Lorg/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest;Ljava/lang/Object;Ljava/lang/Object;JLjava/util/List;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest; - public fun equals (Ljava/lang/Object;)Z - public fun getCreated ()J - public fun getKey ()Ljava/lang/Object; - public fun getOnCompletions ()Ljava/util/List; - public fun getValue ()Ljava/lang/Object; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/impl/extensions/StoreKt { - public static final fun asMutableStore (Lorg/mobilenativefoundation/store/store5/Store;Lorg/mobilenativefoundation/store/store5/Updater;Lorg/mobilenativefoundation/store/store5/Bookkeeper;)Lorg/mobilenativefoundation/store/store5/MutableStore; - public static final fun fresh (Lorg/mobilenativefoundation/store/store5/MutableStore;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static final fun fresh (Lorg/mobilenativefoundation/store/store5/Store;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static final fun get (Lorg/mobilenativefoundation/store/store5/MutableStore;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static final fun get (Lorg/mobilenativefoundation/store/store5/Store;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - -public abstract class org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult { -} - -public abstract class org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error : org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult { -} - -public final class org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Exception : org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error { - public fun (Ljava/lang/Throwable;)V - public final fun component1 ()Ljava/lang/Throwable; - public final fun copy (Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Exception; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Exception;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Exception; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Throwable; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Message : org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error { - public fun (Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Message; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Message;Ljava/lang/String;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Error$Message; - public fun equals (Ljava/lang/Object;)Z - public final fun getMessage ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public abstract class org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success : org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult { -} - -public final class org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success$ConflictsResolved : org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success { - public fun (Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success;)V - public final fun component1 ()Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success; - public final fun copy (Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success;)Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success$ConflictsResolved; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success$ConflictsResolved;Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success$ConflictsResolved; - public fun equals (Ljava/lang/Object;)Z - public final fun getValue ()Lorg/mobilenativefoundation/store/store5/UpdaterResult$Success; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success$NoConflicts : org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success { - public static final field INSTANCE Lorg/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult$Success$NoConflicts; -} - -public abstract class org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult { -} - -public abstract class org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error : org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult { -} - -public final class org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Exception : org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error { - public fun (Ljava/lang/Throwable;)V - public final fun component1 ()Ljava/lang/Throwable; - public final fun copy (Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Exception; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Exception;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Exception; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/Throwable; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Message : org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error { - public fun (Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;)Lorg/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Message; - public static synthetic fun copy$default (Lorg/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Message;Ljava/lang/String;ILjava/lang/Object;)Lorg/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Error$Message; - public fun equals (Ljava/lang/Object;)Z - public final fun getError ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Success : org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult { - public static final field INSTANCE Lorg/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult$Success; -} - diff --git a/store/build.gradle.kts b/store/build.gradle.kts deleted file mode 100644 index 900c73d61..000000000 --- a/store/build.gradle.kts +++ /dev/null @@ -1,47 +0,0 @@ -plugins { - id("org.mobilenativefoundation.store.multiplatform") - alias(libs.plugins.kover) -} - -kotlin { - sourceSets { - commonMain { - dependencies { - implementation(libs.kotlin.stdlib) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.serialization.core) - api(libs.kotlinx.atomic.fu) - implementation(libs.touchlab.kermit) - implementation(projects.multicast) - implementation(projects.cache) - api(projects.core) - } - } - - commonTest { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.turbine) - } - } - } -} - -kotlin { - android { - namespace = "org.mobilenativefoundation.store.store5" - } -} - -kover { - - reports { - total { - xml { - onCheck = true - xmlFile.set(file("${layout.buildDirectory}/reports/kover/coverage.xml")) - } - } - } -} diff --git a/store/config/ktlint/baseline.xml b/store/config/ktlint/baseline.xml deleted file mode 100644 index 0642aee1c..000000000 --- a/store/config/ktlint/baseline.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/store/gradle.properties b/store/gradle.properties deleted file mode 100644 index 5a52f2f4e..000000000 --- a/store/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -POM_NAME=org.mobilenativefoundation.store -POM_ARTIFACT_ID=store5 -POM_PACKAGING=jar \ No newline at end of file diff --git a/store/src/androidMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.android.kt b/store/src/androidMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.android.kt deleted file mode 100644 index 08d6e9b49..000000000 --- a/store/src/androidMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.android.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -internal actual fun currentTimeMillis(): Long = System.currentTimeMillis() diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Bookkeeper.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Bookkeeper.kt deleted file mode 100644 index 35b3e42db..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Bookkeeper.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import org.mobilenativefoundation.store.store5.impl.RealBookkeeper -import org.mobilenativefoundation.store.store5.impl.RealMutableStore -import org.mobilenativefoundation.store.store5.impl.extensions.now - -/** - * Tracks when local changes fail to sync with network. - * @see [RealMutableStore] usage to persist write request failures and eagerly resolve conflicts before completing a read request. - */ - -interface Bookkeeper { - suspend fun getLastFailedSync(key: Key): Long? - - suspend fun setLastFailedSync( - key: Key, - timestamp: Long = now(), - ): Boolean - - suspend fun clear(key: Key): Boolean - - suspend fun clearAll(): Boolean - - companion object { - fun by( - getLastFailedSync: suspend (key: Key) -> Long?, - setLastFailedSync: suspend (key: Key, timestamp: Long) -> Boolean, - clear: suspend (key: Key) -> Boolean, - clearAll: suspend () -> Boolean, - ): Bookkeeper = RealBookkeeper(getLastFailedSync, setLastFailedSync, clear, clearAll) - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Clear.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Clear.kt deleted file mode 100644 index 0311d7455..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Clear.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi - -interface Clear { - interface Key { - /** - * Purge a particular entry from memory and disk cache. - * Persistent storage will only be cleared if a delete function was passed to - * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. - */ - suspend fun clear(key: Key) - } - - interface All { - /** - * Purge all entries from memory and disk cache. - * Persistent storage will only be cleared if a clear function was passed to - * [StoreBuilder.persister] or [StoreBuilder.nonFlowingPersister] when creating the [Store]. - */ - @ExperimentalStoreApi - suspend fun clear() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Converter.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Converter.kt deleted file mode 100644 index b6bbe74a3..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Converter.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -/** - * Converter is a utility interface that can be used to convert a network or output model to a local model. - * Network to Local conversion is needed when the network model is different what you are saving in - * your Source of Truth. - * Output to Local conversion is needed when you are doing local writes in a MutableStore - * @param Network The network data source model type. This is the type used in [Fetcher] - * @param Output The common model type emitted from Store, typically the type returend from your Source of Truth - * @param Local The local data source model type. This is the type used to save to your Source of Truth - */ -interface Converter { - fun fromNetworkToLocal(network: Network): Local - - fun fromOutputToLocal(output: Output): Local - - class Builder { - lateinit var fromOutputToLocal: ((output: Output) -> Local) - lateinit var fromNetworkToLocal: ((network: Network) -> Local) - - fun build(): Converter = RealConverter(fromOutputToLocal, fromNetworkToLocal) - - fun fromOutputToLocal(converter: (output: Output) -> Local): Builder { - fromOutputToLocal = converter - return this - } - - fun fromNetworkToLocal(converter: (network: Network) -> Local): Builder { - fromNetworkToLocal = converter - return this - } - } -} - -private class RealConverter( - private val fromOutputToLocal: ((output: Output) -> Local), - private val fromNetworkToLocal: ((network: Network) -> Local), -) : Converter { - override fun fromNetworkToLocal(network: Network): Local = fromNetworkToLocal.invoke(network) - - override fun fromOutputToLocal(output: Output): Local = fromOutputToLocal.invoke(output) -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Fetcher.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Fetcher.kt deleted file mode 100644 index 940e1ee20..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Fetcher.kt +++ /dev/null @@ -1,200 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import org.mobilenativefoundation.store.store5.Fetcher.Companion.of -import org.mobilenativefoundation.store.store5.Fetcher.Companion.ofFlow -import org.mobilenativefoundation.store.store5.Fetcher.Companion.ofResult - -/** - * Fetcher is used by [Store] to fetch network records for a given key. The return type is [Flow] to - * allow for multiple result per request. - * - * Note: Store does not catch exceptions thrown by a [Fetcher]. This is done in order to avoid - * silently swallowing NPEs and such. Use [FetcherResult.Error] to communicate expected errors. - * - * See [ofResult] for easily translating from a regular `suspend` function. - * See [ofFlow], [of] for easily translating to [FetcherResult] (and - * automatically transforming exceptions into [FetcherResult.Error]). - * - * @property name Unique name to enable differentiation when [fallback] exists. - */ -interface Fetcher { - val name: String? - - val fallback: Fetcher? - - /** - * Returns a flow of the item represented by the given [key]. - */ - operator fun invoke(key: Key): Flow> - - companion object { - /** - * "Creates" a [Fetcher] from a [flowFactory]. - * - * Use when creating a [Store] that fetches objects in a multiple responses per request - * network protocol (e.g., Web Sockets). - * - * [Store] does not catch exception thrown in [flowFactory] or in the returned [Flow]. These - * exception will be propagated to the caller. - * - * @param flowFactory a factory for a [Flow]ing source of network records. - */ - fun ofResultFlow(flowFactory: (Key) -> Flow>): Fetcher = - FactoryFetcher(factory = flowFactory) - - /** - * Creates a [Fetcher] with a [fallback] from a [flowFactory]. - * Use instead of [ofResultFlow] if implementing fallback mechanisms. - * @param name Unique name to enable differentiation of fetchers. - */ - fun ofResultFlowWithFallback( - name: String, - flowFactory: (Key) -> Flow>, - fallback: Fetcher, - ): Fetcher = FactoryFetcherWithFallback(name = name, factory = flowFactory, fallback = fallback) - - /** - * "Creates" a [Fetcher] from a non-[Flow] source. - * - * Use when creating a [Store] that fetches objects in a single response per request network - * protocol (e.g., Http). - * - * [Store] does not catch exception thrown in [fetch]. These exception will be propagated to the - * caller. - * - * @param fetch a source of network records. - */ - fun ofResult(fetch: suspend (Key) -> FetcherResult): Fetcher = - ofResultFlow(fetch.asFlow()) - - /** - * Creates a [Fetcher] with a [fallback] from a non-Flow source. - * Use instead of [ofResult] if implementing fallback mechanisms. - * @param name Unique name to enable differentiation of fetchers. - */ - fun ofResultWithFallback( - name: String, - fetch: suspend (Key) -> FetcherResult, - fallback: Fetcher, - ): Fetcher = ofResultFlowWithFallback(name, fetch.asFlow(), fallback) - - /** - * "Creates" a [Fetcher] from a [flowFactory] and translate the results to a [FetcherResult]. - * - * Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the flow then - * it will be wrapped in [FetcherResult.Error]. Exceptions thrown in [flowFactory] itself are not - * caught and will be returned to the caller. - * - * Use when creating a [Store] that fetches objects in a multiple responses per request - * network protocol (e.g Web Sockets). - * - * @param flowFactory a factory for a [Flow]ing source of network records. - */ - fun ofFlow( - name: String? = null, - flowFactory: (Key) -> Flow, - ): Fetcher = - FactoryFetcher { key: Key -> - flowFactory(key) - .map> { FetcherResult.Data(it, name) } - .catch { throwable: Throwable -> emit(FetcherResult.Error.Exception(throwable)) } - } - - /** - * Creates a [Fetcher] with a [fallback] from a [flowFactory]. - * Use instead of [ofFlow] if implementing fallback mechanisms. - * @param name Unique name to enable differentiation of fetchers - */ - fun ofFlowWithFallback( - name: String, - fallback: Fetcher, - flowFactory: (Key) -> Flow, - ): Fetcher = - FactoryFetcherWithFallback(name = name, factory = { key: Key -> - flowFactory(key) - .map> { - FetcherResult.Data(it, name) - } - .catch { throwable: Throwable -> emit(FetcherResult.Error.Exception(throwable)) } - }, fallback = fallback) - - /** - * "Creates" a [Fetcher] from a non-[Flow] source and translate the results to a [FetcherResult]. - * - * Emitted values will be wrapped in [FetcherResult.Data]. if an exception disrupts the flow then - * it will be wrapped in [FetcherResult.Error] - * - * Use when creating a [Store] that fetches objects in a single response per request - * network protocol (e.g Http). - * - * @param fetch a source of network records. - */ - fun of( - name: String? = null, - fetch: suspend (key: Key) -> Network, - ): Fetcher = ofFlow(name, fetch.asFlow()) - - /** - * Creates a [Fetcher] with a [fallback] from a non-Flow source. - * Use instead of [of] if implementing fallback mechanisms. - * @param name Unique name to enable differentiation of fetchers - */ - fun withFallback( - name: String, - fallback: Fetcher, - fetch: suspend (key: Key) -> Network, - ): Fetcher = ofFlowWithFallback(name, fallback, fetch.asFlow()) - - private fun (suspend (key: Key) -> Network).asFlow() = - { key: Key -> - flow { - emit(invoke(key)) - } - } - - private class FactoryFetcher( - private val factory: (Key) -> Flow>, - ) : Fetcher { - override val name: String? = null - override val fallback: Fetcher? = null - - override fun invoke(key: Key): Flow> = factory(key) - } - - private fun tryFetch( - key: Key, - factory: (Key) -> Flow>, - fallback: Fetcher?, - ): Flow> = - channelFlow { - factory(key).collect { fetcherResult -> - when (fetcherResult) { - is FetcherResult.Data -> { - send(fetcherResult) - } - - is FetcherResult.Error -> { - if (fallback != null) { - tryFetch(key, fallback::invoke, fallback.fallback).collect { send(it) } - } else { - send(fetcherResult) - } - } - } - } - } - - private class FactoryFetcherWithFallback( - override val name: String, - private val factory: (Key) -> Flow>, - override val fallback: Fetcher, - ) : Fetcher { - override fun invoke(key: Key): Flow> = tryFetch(key, factory, fallback) - } - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/FetcherResult.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/FetcherResult.kt deleted file mode 100644 index 1160e5416..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/FetcherResult.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -sealed class FetcherResult { - data class Data(val value: Network, val origin: String? = null) : FetcherResult() - - sealed class Error : FetcherResult() { - data class Exception(val error: Throwable) : Error() - - data class Message(val message: String) : Error() - - data class Custom(val error: E) : Error() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Logger.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Logger.kt deleted file mode 100644 index d7318b9af..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Logger.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -/** - * A simple logging interface for logging error and debug messages. - */ -interface Logger { - /** - * Logs an error message, optionally with a throwable. - * - * @param message The error message to log. - * @param throwable An optional [Throwable] associated with the error. - */ - fun error( - message: String, - throwable: Throwable? = null, - ) - - /** - * Logs a debug message. - * - * @param message The debug message to log. - */ - fun debug(message: String) -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MemoryPolicy.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MemoryPolicy.kt deleted file mode 100644 index fe1e816f4..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MemoryPolicy.kt +++ /dev/null @@ -1,116 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import org.mobilenativefoundation.store.cache5.Cache -import kotlin.time.Duration - -fun interface Weigher { - /** - * Returns the weight of a cache entry. There is no unit for entry weights; rather they are simply - * relative to each other. - * - * @return the weight of the entry; must be non-negative - */ - fun weigh( - key: K, - value: V, - ): Int -} - -internal object OneWeigher : Weigher { - override fun weigh( - key: Any, - value: Any, - ): Int = 1 -} - -/** - * Defines behavior of in-memory [Cache]. - * Used by [Store]. - * @see [Store] - */ -class MemoryPolicy internal constructor( - val expireAfterWrite: Duration, - val expireAfterAccess: Duration, - val maxSize: Long, - val maxWeight: Long, - val weigher: Weigher, -) { - val isDefaultWritePolicy: Boolean = expireAfterWrite == DEFAULT_DURATION_POLICY - - val hasWritePolicy: Boolean = expireAfterWrite != DEFAULT_DURATION_POLICY - - val hasAccessPolicy: Boolean = expireAfterAccess != DEFAULT_DURATION_POLICY - - val hasMaxSize: Boolean = maxSize != DEFAULT_SIZE_POLICY - - val hasMaxWeight: Boolean = maxWeight != DEFAULT_SIZE_POLICY - - class MemoryPolicyBuilder { - private var expireAfterWrite = DEFAULT_DURATION_POLICY - private var expireAfterAccess = DEFAULT_DURATION_POLICY - private var maxSize: Long = DEFAULT_SIZE_POLICY - private var maxWeight: Long = DEFAULT_SIZE_POLICY - private var weigher: Weigher = OneWeigher - - fun setExpireAfterWrite(expireAfterWrite: Duration): MemoryPolicyBuilder = - apply { - check(expireAfterAccess == DEFAULT_DURATION_POLICY) { - "Cannot set expireAfterWrite with expireAfterAccess already set" - } - this.expireAfterWrite = expireAfterWrite - } - - fun setExpireAfterAccess(expireAfterAccess: Duration): MemoryPolicyBuilder = - apply { - check(expireAfterWrite == DEFAULT_DURATION_POLICY) { - "Cannot set expireAfterAccess with expireAfterWrite already set" - } - this.expireAfterAccess = expireAfterAccess - } - - /** - * Sets the maximum number of items ([maxSize]) kept in the cache. - * - * When [maxSize] is 0, entries will be discarded immediately and no values will be cached. - * - * If not set, cache size will be unlimited. - */ - fun setMaxSize(maxSize: Long): MemoryPolicyBuilder = - apply { - check(maxWeight == DEFAULT_SIZE_POLICY && weigher == OneWeigher) { - "Cannot setMaxSize when maxWeight or weigher are already set" - } - check(maxSize >= 0) { "maxSize cannot be negative" } - this.maxSize = maxSize - } - - fun setWeigherAndMaxWeight( - weigher: Weigher, - maxWeight: Long, - ): MemoryPolicyBuilder = - apply { - check(maxSize == DEFAULT_SIZE_POLICY) { - "Cannot setWeigherAndMaxWeight when maxSize already set" - } - check(maxWeight >= 0) { "maxWeight cannot be negative" } - this.weigher = weigher - this.maxWeight = maxWeight - } - - fun build() = - MemoryPolicy( - expireAfterWrite = expireAfterWrite, - expireAfterAccess = expireAfterAccess, - maxSize = maxSize, - maxWeight = maxWeight, - weigher = weigher, - ) - } - - companion object { - val DEFAULT_DURATION_POLICY: Duration = Duration.INFINITE - const val DEFAULT_SIZE_POLICY: Long = -1 - - fun builder(): MemoryPolicyBuilder = MemoryPolicyBuilder() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStore.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStore.kt deleted file mode 100644 index 8018451c5..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStore.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi - -@ExperimentalStoreApi -interface MutableStore : - Read.StreamWithConflictResolution, - Write, - Write.Stream, - Clear.Key, - Clear diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStoreBuilder.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStoreBuilder.kt deleted file mode 100644 index 21ec51c89..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/MutableStoreBuilder.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.CoroutineScope -import org.mobilenativefoundation.store.store5.impl.mutableStoreBuilderFromFetcherAndSourceOfTruth - -interface MutableStoreBuilder { - fun build( - updater: Updater, - bookkeeper: Bookkeeper? = null, - ): MutableStore - - /** - * A store multicasts same [Output] value to many consumers (Similar to RxJava.share()), by default - * [Store] will open a global scope for management of shared responses, if instead you'd like to control - * the scope that sharing/multicasting happens in you can pass a @param [scope] - * - * @param scope - scope to use for sharing - */ - fun scope(scope: CoroutineScope): MutableStoreBuilder - - /** - * controls eviction policy for a store cache, use [MemoryPolicy.MemoryPolicyBuilder] to configure a TTL - * or size based eviction - * Example: MemoryPolicy.builder().setExpireAfterWrite(10.seconds).build() - */ - fun cachePolicy(memoryPolicy: MemoryPolicy?): MutableStoreBuilder - - /** - * by default a Store caches in memory with a default policy of max items = 100 - */ - fun disableCache(): MutableStoreBuilder - - fun validator(validator: Validator): MutableStoreBuilder - - companion object { - /** - * Creates a new [MutableStoreBuilder] from a [Fetcher] and a [SourceOfTruth]. - * - * @param fetcher a function for fetching a flow of network records. - * @param sourceOfTruth a [SourceOfTruth] for the store. - */ - fun from( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - converter: Converter, - ): MutableStoreBuilder = - mutableStoreBuilderFromFetcherAndSourceOfTruth( - fetcher = fetcher, - sourceOfTruth = sourceOfTruth, - converter = converter, - ) - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnFetcherCompletion.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnFetcherCompletion.kt deleted file mode 100644 index 4ad18f3a1..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnFetcherCompletion.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -data class OnFetcherCompletion( - val onSuccess: (FetcherResult.Data) -> Unit, - val onFailure: (FetcherResult.Error) -> Unit, -) diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnUpdaterCompletion.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnUpdaterCompletion.kt deleted file mode 100644 index 84c8544c5..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/OnUpdaterCompletion.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -data class OnUpdaterCompletion( - val onSuccess: (UpdaterResult.Success) -> Unit, - val onFailure: (UpdaterResult.Error) -> Unit, -) diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Read.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Read.kt deleted file mode 100644 index e5c142b8f..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Read.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.flow.Flow - -interface Read { - interface Stream { - /** - * Return a flow for the given key - * @param request - see [StoreReadRequest] for configurations - */ - fun stream(request: StoreReadRequest): Flow> - } - - interface StreamWithConflictResolution { - fun stream(request: StoreReadRequest): Flow> - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruth.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruth.kt deleted file mode 100644 index 08a4fe3f8..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruth.kt +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.flow.Flow -import org.mobilenativefoundation.store.store5.impl.PersistentNonFlowingSourceOfTruth -import org.mobilenativefoundation.store.store5.impl.PersistentSourceOfTruth -import kotlin.jvm.JvmName - -/** - * - * [SourceOfTruth], as name implies, is the persistence API which [Store] uses to serve values to - * the collectors. If provided, [Store] will only return values received from [SourceOfTruth] back - * to the collectors. - * - * In other words, values coming from the [Fetcher] will always be sent to the [SourceOfTruth] - * and will be read back via [reader] to be returned to the collector. - * - * This round-trip ensures the data is consistent across the application in case the [Fetcher] may - * not return all fields or return a different class type than the app uses. It is particularly - * useful if your application has a local observable database which is directly modified by the app - * as Store can observe these changes and update the collectors even before value is synced to the - * backend. - * - * Source of truth takes care of making any source (no matter if it has flowing reads or not) into - * a common flowing API. - * - * A source of truth is usually backed by local storage. It's purpose is to eliminate the need - * for waiting on network update before local modifications are available (via [Store.Stream.read]). - * - * For maximal flexibility, [writer]'s record type ([Input]] and [reader]'s record type - * ([Output]) are not identical. This allows us to read one type of objects from network and - * transform them to another type when placing them in local storage. - * - */ -interface SourceOfTruth { - /** - * Used by [Store] to read records from the source of truth. - * - * @param key The key to read for. - */ - fun reader(key: Key): Flow - - /** - * Used by [Store] to write records **coming in from the fetcher (network)** to the source of - * truth. - * - * **Note:** [Store] currently does not support updating the source of truth with local user - * updates (i.e writing record of type [Output]). However, any changes in the local database - * will still be visible via [Store.Stream.read] APIs as long as you are using a local storage that - * supports observability (e.g. Room, SQLDelight, Realm). - * - * @param key The key to update for. - */ - suspend fun write( - key: Key, - value: Local, - ) - - /** - * Used by [Store] to delete records in the source of truth for the given key. - * - * @param key The key to delete for. - */ - suspend fun delete(key: Key) - - /** - * Used by [Store] to delete all records in the source of truth. - */ - suspend fun deleteAll() - - companion object { - /** - * Creates a (non-[Flow]) source of truth that is accessible via [nonFlowReader], [writer], - * [delete] and [deleteAll]. - * - * @param nonFlowReader function for reading records from the source of truth - * @param writer function for writing updates to the backing source of truth - * @param delete function for deleting records in the source of truth for the given key - * @param deleteAll function for deleting all records in the source of truth - */ - fun of( - nonFlowReader: suspend (Key) -> Output?, - writer: suspend (Key, Local) -> Unit, - delete: (suspend (Key) -> Unit)? = null, - deleteAll: (suspend () -> Unit)? = null, - ): SourceOfTruth = - PersistentNonFlowingSourceOfTruth( - realReader = nonFlowReader, - realWriter = writer, - realDelete = delete, - realDeleteAll = deleteAll, - ) - - /** - * Creates a ([Flow]) source of truth that is accessed via [reader], [writer], - * [delete] and [deleteAll]. - * - * @param reader function for reading records from the source of truth - * @param writer function for writing updates to the backing source of truth - * @param delete function for deleting records in the source of truth for the given key - * @param deleteAll function for deleting all records in the source of truth - */ - @JvmName("ofFlow") - fun of( - reader: (Key) -> Flow, - writer: suspend (Key, Local) -> Unit, - delete: (suspend (Key) -> Unit)? = null, - deleteAll: (suspend () -> Unit)? = null, - ): SourceOfTruth = - PersistentSourceOfTruth( - realReader = reader, - realWriter = writer, - realDelete = delete, - realDeleteAll = deleteAll, - ) - } - - /** - * The exception provided when a write operation fails in SourceOfTruth. - * - * see [StoreReadResponse.Error.Exception] - */ - class WriteException( - /** - * The key for the failed write attempt - */ - val key: Any?, // TODO why are we not marking keys non-null ? - /** - * The value for the failed write attempt - */ - val value: Any?, - /** - * The exception thrown from the [SourceOfTruth]'s [write] method. - */ - cause: Throwable, - ) : RuntimeException( - "Failed to write value to Source of Truth. key: $key", - cause, - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as WriteException - - if (key != other.key) return false - if (value != other.value) return false - if (cause != other.cause) return false - return true - } - - override fun hashCode(): Int { - var result = key.hashCode() - result = 31 * result + value.hashCode() - return result - } - } - - /** - * Exception created when a [reader] throws an exception. - * - * see [StoreReadResponse.Error.Exception] - */ - class ReadException( - /** - * The key for the failed write attempt - */ - val key: Any?, // TODO shouldn't key be non-null? - cause: Throwable, - ) : RuntimeException( - "Failed to read from Source of Truth. key: $key", - cause, - ) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || this::class != other::class) return false - - other as ReadException - - if (key != other.key) return false - if (cause != other.cause) return false - return true - } - - override fun hashCode(): Int { - return key.hashCode() - } - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Store.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Store.kt deleted file mode 100644 index 6fc93df2e..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Store.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -/** - * A Store is responsible for managing a particular data request. - * - * When you create an implementation of a Store, you provide it with a Fetcher, a function that defines how data will be fetched over network. - * - * You can also define how your Store will cache data in-memory and on-disk. See [StoreBuilder] for full configuration - * - * Example usage: - * - * val store = StoreBuilder - * .fromNonFlow, List> { (query, config) -> - * provideRetrofit().fetchData(query, config.limit).data.children.map(::toPosts) - * } - * .persister(reader = { (query, _) -> db.postDao().loadData(query) }, - * writer = { (query, _), posts -> db.dataDAO().insertData(query, posts) }, - * delete = { (query, _) -> db.dataDAO().clearData(query) }, - * deleteAll = db.postDao()::clearAllFeeds) - * .build() - * - * // single shot response - * viewModelScope.launch { - * val data = store.fresh(key) - * } - * - * // get cached data and collect future emissions as well - * viewModelScope.launch { - * val data = store.cached(key, refresh=true) - * .collect{data.value=it } - * } - * - */ -interface Store : - Read.Stream, - Clear.Key, - Clear.All diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreBuilder.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreBuilder.kt deleted file mode 100644 index f91b94af4..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreBuilder.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.CoroutineScope -import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.store5.impl.storeBuilderFromFetcher -import org.mobilenativefoundation.store.store5.impl.storeBuilderFromFetcherAndSourceOfTruth -import org.mobilenativefoundation.store.store5.impl.storeBuilderFromFetcherSourceOfTruthAndMemoryCache -import org.mobilenativefoundation.store.store5.impl.storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter - -/** - * Main entry point for creating a [Store]. - */ -interface StoreBuilder { - fun build(): Store - - fun toMutableStoreBuilder( - converter: Converter, - ): MutableStoreBuilder - - /** - * A store multicasts same [Output] value to many consumers (Similar to RxJava.share()), by default - * [Store] will open a global scope for management of shared responses, if instead you'd like to control - * the scope that sharing/multicasting happens in you can pass a @param [scope] - * - * @param scope - scope to use for sharing - */ - fun scope(scope: CoroutineScope): StoreBuilder - - /** - * controls eviction policy for a store cache, use [MemoryPolicy.MemoryPolicyBuilder] to configure a TTL - * or size based eviction - * Example: MemoryPolicy.builder().setExpireAfterWrite(10.seconds).build() - */ - fun cachePolicy(memoryPolicy: MemoryPolicy?): StoreBuilder - - /** - * by default a Store caches in memory with a default policy of max items = 100 - */ - fun disableCache(): StoreBuilder - - fun validator(validator: Validator): StoreBuilder - - companion object { - /** - * Creates a new [StoreBuilder] from a [Fetcher]. - * - * @param fetcher a [Fetcher] flow of network records. - */ - fun from(fetcher: Fetcher): StoreBuilder = - storeBuilderFromFetcher(fetcher = fetcher) - - /** - * Creates a new [StoreBuilder] from a [Fetcher] and a [SourceOfTruth]. - * - * @param fetcher a function for fetching a flow of network records. - * @param sourceOfTruth a [SourceOfTruth] for the store. - */ - fun from( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - ): StoreBuilder = storeBuilderFromFetcherAndSourceOfTruth(fetcher = fetcher, sourceOfTruth = sourceOfTruth) - - fun from( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - memoryCache: Cache, - ): StoreBuilder = - storeBuilderFromFetcherSourceOfTruthAndMemoryCache( - fetcher, - sourceOfTruth, - memoryCache, - ) - - fun from( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - converter: Converter, - ): StoreBuilder = - storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter( - fetcher, - sourceOfTruth, - null, - converter, - ) - - fun from( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - memoryCache: Cache, - converter: Converter, - ): StoreBuilder = - storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter( - fetcher, - sourceOfTruth, - memoryCache, - converter, - ) - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreDefaults.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreDefaults.kt deleted file mode 100644 index 341f3e579..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreDefaults.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlin.time.Duration -import kotlin.time.Duration.Companion.hours - -internal object StoreDefaults { - /** - * Cache TTL (default is 24 hours), can be overridden - * - * @return memory cache TTL - */ - val cacheTTL: Duration = 24.hours - - /** - * Cache size (default is 100), can be overridden - * - * @return memory cache size - */ - val cacheSize: Long = 100 - - val memoryPolicy = - MemoryPolicy.builder() - .setMaxSize(cacheSize) - .setExpireAfterWrite(cacheTTL) - .build() -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt deleted file mode 100644 index d31fea5c4..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadRequest.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5 - -/** - * data class to represent a single store request - * @param key a unique identifier for your data - * @param skippedCaches List of cache types that should be skipped when retuning the response see [CacheType] - * @param refresh If set to true [Store] will always get fresh value from fetcher while also - * starting the stream from the local [com.dropbox.android.external.store4.impl.SourceOfTruth] and memory cache - * @param fetch If set to false, then fetcher will not be used - */ -data class StoreReadRequest private constructor( - val key: Key, - private val skippedCaches: Int, - val refresh: Boolean = false, - val fallBackToSourceOfTruth: Boolean = false, - val fetch: Boolean = true, -) { - internal fun shouldSkipCache(type: CacheType) = skippedCaches.and(type.flag) != 0 - - /** - * Factories for common store requests - */ - companion object { - private val allCaches = - CacheType.values().fold(0) { prev, next -> - prev.or(next.flag) - } - - /** - * Create a [StoreReadRequest] which will skip all caches and hit your fetcher - * (filling your caches). - * - * Note: If the [Fetcher] does not return any data (i.e., the returned - * [kotlinx.coroutines.Flow], when collected, is empty). Then store will fall back to local - * data **even** if you explicitly requested fresh data. - * See https://github.com/dropbox/Store/pull/194 for context. - */ - fun fresh( - key: Key, - fallBackToSourceOfTruth: Boolean = false, - ) = StoreReadRequest( - key = key, - skippedCaches = allCaches, - refresh = true, - fallBackToSourceOfTruth = fallBackToSourceOfTruth, - ) - - /** - * Create a [StoreReadRequest] which will return data from memory/disk caches if present, - * otherwise will hit your fetcher (filling your caches). - * @param refresh if true then return fetcher (new) data as well (updating your caches) - */ - fun cached( - key: Key, - refresh: Boolean, - ) = StoreReadRequest( - key = key, - skippedCaches = 0, - refresh = refresh, - ) - - /** - * Create a [StoreReadRequest] which will return data from memory/disk caches if present, - * otherwise will return [StoreReadResponse.NoNewData] - */ - fun localOnly(key: Key) = - StoreReadRequest( - key = key, - skippedCaches = 0, - fetch = false, - ) - - /** - * Create a [StoreReadRequest] which will return data from disk cache - * @param refresh if true then return fetcher (new) data as well (updating your caches) - */ - fun skipMemory( - key: Key, - refresh: Boolean, - ) = StoreReadRequest( - key = key, - skippedCaches = CacheType.MEMORY.flag, - refresh = refresh, - ) - - /** - * Creates a [StoreReadRequest] skipping all caches and returning data from network on success and data from [SourceOfTruth] on failure. - */ - fun freshWithFallBackToSourceOfTruth(key: Key) = fresh(key, fallBackToSourceOfTruth = true) - } -} - -internal enum class CacheType(internal val flag: Int) { - MEMORY(0b01), - DISK(0b10), -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt deleted file mode 100644 index edef2ce81..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponse.kt +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5 - -/** - * Holder for responses from Store. - * - * Instead of using regular error channels (a.k.a. throwing exceptions), Store uses this holder - * class to represent each response. This allows the flow to keep running even if an error happens - * so that if there is an observable single source of truth, application can keep observing it. - */ -sealed class StoreReadResponse { - /** - * Represents the source of the Response. - */ - abstract val origin: StoreReadResponseOrigin - - object Initial : StoreReadResponse() { - override val origin: StoreReadResponseOrigin = StoreReadResponseOrigin.Initial - } - - /** - * Loading event dispatched by [Store] to signal the [Fetcher] is in progress. - */ - data class Loading(override val origin: StoreReadResponseOrigin) : StoreReadResponse() - - /** - * Data dispatched by [Store] - */ - data class Data(val value: Output, override val origin: StoreReadResponseOrigin) : - StoreReadResponse() - - /** - * No new data event dispatched by Store to signal the [Fetcher] returned no data (i.e., the - * returned [kotlinx.coroutines.flow.Flow], when collected, was empty). - */ - data class NoNewData(override val origin: StoreReadResponseOrigin) : StoreReadResponse() - - /** - * Error dispatched by a pipeline - */ - sealed class Error : StoreReadResponse() { - data class Exception( - val error: Throwable, - override val origin: StoreReadResponseOrigin, - ) : Error() - - data class Message( - val message: String, - override val origin: StoreReadResponseOrigin, - ) : Error() - - data class Custom( - val error: E, - override val origin: StoreReadResponseOrigin, - ) : Error() - } - - /** - * Returns the available data or throws [NullPointerException] if there is no data. - */ - fun requireData(): Output { - return when (this) { - is Data -> value - is Error -> throw this.doThrow() - else -> throw NullPointerException("there is no data in $this") - } - } - - /** - * If this [StoreReadResponse] is of type [StoreReadResponse.Error], throws the exception - * Otherwise, does nothing. - */ - fun throwIfError() { - if (this is Error) { - throw this.doThrow() - } - } - - /** - * If this [StoreReadResponse] is of type [StoreReadResponse.Error], returns the available error - * from it. Otherwise, returns `null`. - */ - fun errorMessageOrNull(): String? { - return when (this) { - is Error.Message -> message - is Error.Exception -> error.message ?: "exception: ${error::class}" - else -> null - } - } - - /** - * If there is data available, returns it; otherwise returns null. - */ - fun dataOrNull(): Output? = - when (this) { - is Data -> value - else -> null - } - - private fun errorOrNull(): Throwable? { - if (this is Error.Exception) { - return error - } - - return null - } - - /** - * @returns Error if there is one, else null. - */ - @Suppress("UNCHECKED_CAST") - fun errorOrNull(): E? { - if (this is Error.Custom<*>) { - return (this as? Error.Custom)?.error - } - - return errorOrNull() as? E - } - - @Suppress("UNCHECKED_CAST") - internal fun swapType(): StoreReadResponse = - when (this) { - is Error -> this - is Loading -> this - is NoNewData -> this - is Data -> throw RuntimeException("cannot swap type for StoreResponse.Data") - is Initial -> this - } -} - -/** - * Represents the origin for a [StoreReadResponse]. - */ -sealed class StoreReadResponseOrigin { - /** - * [StoreReadResponse] is sent from the cache - */ - object Cache : StoreReadResponseOrigin() - - /** - * [StoreReadResponse] is sent from the persister - */ - object SourceOfTruth : StoreReadResponseOrigin() - - /** - * [StoreReadResponse] is sent from a fetcher - * @property name Unique name to enable differentiation when [org.mobilenativefoundation.store.store5.Fetcher.fallback] exists - */ - data class Fetcher(val name: String? = null) : StoreReadResponseOrigin() - - object Initial : StoreReadResponseOrigin() -} - -fun StoreReadResponse.Error.doThrow(): Throwable { - return when (this) { - is StoreReadResponse.Error.Exception -> error - is StoreReadResponse.Error.Message -> RuntimeException(message) - is StoreReadResponse.Error.Custom<*> -> { - if (error is Throwable) { - error - } else { - RuntimeException("Non-throwable custom error: $error") - } - } - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteRequest.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteRequest.kt deleted file mode 100644 index d76c68ea2..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteRequest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import org.mobilenativefoundation.store.store5.impl.OnStoreWriteCompletion -import org.mobilenativefoundation.store.store5.impl.RealStoreWriteRequest -import org.mobilenativefoundation.store.store5.impl.extensions.currentTimeMillis - -interface StoreWriteRequest { - val key: Key - val value: Output - val created: Long - val onCompletions: List? - - companion object { - fun of( - key: Key, - value: Output, - onCompletions: List? = null, - created: Long = currentTimeMillis(), - ): StoreWriteRequest = RealStoreWriteRequest(key, value, created, onCompletions) - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteResponse.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteResponse.kt deleted file mode 100644 index d0e6e3844..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/StoreWriteResponse.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -sealed class StoreWriteResponse { - sealed class Success : StoreWriteResponse() { - data class Typed(val value: Response) : Success() - - data class Untyped(val value: Any) : Success() - } - - sealed class Error : StoreWriteResponse() { - data class Exception(val error: Throwable) : Error() - - data class Message(val message: String) : Error() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Updater.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Updater.kt deleted file mode 100644 index 8913ba619..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Updater.kt +++ /dev/null @@ -1,43 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -typealias PostRequest = suspend (key: Key, value: Output) -> UpdaterResult - -/** - * Posts data to remote data source. - * @see [StoreWriteRequest] - */ -interface Updater { - /** - * Makes HTTP POST request. - */ - suspend fun post( - key: Key, - value: Output, - ): UpdaterResult - - /** - * Executes on network completion. - */ - val onCompletion: OnUpdaterCompletion? - - companion object { - fun by( - post: PostRequest, - onCompletion: OnUpdaterCompletion? = null, - ): Updater = - RealNetworkUpdater( - post, - onCompletion, - ) - } -} - -internal class RealNetworkUpdater( - private val realPost: PostRequest, - override val onCompletion: OnUpdaterCompletion?, -) : Updater { - override suspend fun post( - key: Key, - value: Output, - ): UpdaterResult = realPost(key, value) -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/UpdaterResult.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/UpdaterResult.kt deleted file mode 100644 index 2d6a77614..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/UpdaterResult.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -sealed class UpdaterResult { - sealed class Success : UpdaterResult() { - data class Typed(val value: Response) : Success() - - data class Untyped(val value: Any) : Success() - } - - sealed class Error : UpdaterResult() { - data class Exception(val error: Throwable) : Error() - - data class Message(val message: String) : Error() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Validator.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Validator.kt deleted file mode 100644 index 274764b12..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Validator.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import org.mobilenativefoundation.store.store5.impl.RealValidator - -/** - * Enables custom validation of [Store] items. - * @see [StoreReadRequest] - */ -interface Validator { - /** - * Determines whether a [Store] item is valid. - * If invalid, [MutableStore] will get the latest network value using [Fetcher]. - * [MutableStore] will not validate network responses. - */ - suspend fun isValid(item: Output): Boolean - - companion object { - fun by(validator: suspend (item: Output) -> Boolean): Validator = RealValidator(validator) - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Write.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Write.kt deleted file mode 100644 index 9b07fa3f1..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/Write.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.flow.Flow -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi - -interface Write { - @ExperimentalStoreApi - suspend fun write(request: StoreWriteRequest): StoreWriteResponse - - interface Stream { - @ExperimentalStoreApi - fun stream(requestStream: Flow>): Flow - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/DefaultLogger.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/DefaultLogger.kt deleted file mode 100644 index 9219683c3..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/DefaultLogger.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl - -import co.touchlab.kermit.CommonWriter -import org.mobilenativefoundation.store.store5.Logger - -/** - * Default implementation of [Logger] using the Kermit logging library. - */ -internal class DefaultLogger : Logger { - private val delegate = - co.touchlab.kermit.Logger.apply { - setLogWriters(listOf(CommonWriter())) - setTag("Store") - } - - override fun debug(message: String) { - delegate.d(message) - } - - override fun error( - message: String, - throwable: Throwable?, - ) { - delegate.e(message, throwable) - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/FetcherController.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/FetcherController.kt deleted file mode 100644 index d61d492dc..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/FetcherController.kt +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.impl - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEmpty -import kotlinx.coroutines.withContext -import org.mobilenativefoundation.store.multicast5.Multicaster -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin - -/** - * This class maintains one and only 1 fetcher for a given [Key]. - * - * Any value emitted by the fetcher is sent into the [sourceOfTruth] before it is dispatched. - * If [sourceOfTruth] is `null`, [enablePiggyback] is set to true by default so that previous - * fetcher requests receives values dispatched by later requests even if they don't share the - * request. - */ -@Suppress("UNCHECKED_CAST") -internal class FetcherController( - /** - * The [CoroutineScope] to use when collecting from the fetcher - */ - private val scope: CoroutineScope, - /** - * The function that provides the actualy fetcher flow when needed - */ - private val realFetcher: Fetcher, - /** - * [SourceOfTruth] to send the data each time fetcher dispatches a value. Can be `null` if - * no [SourceOfTruth] is available. - */ - private val sourceOfTruth: SourceOfTruthWithBarrier?, - private val converter: Converter = - object : - Converter { - override fun fromNetworkToLocal(network: Network): Local { - return network as Local - } - - override fun fromOutputToLocal(output: Output): Local { - throw IllegalStateException("Not used") - } - }, -) { - @Suppress("USELESS_CAST", "UNCHECKED_CAST") // needed for multicaster source - private val fetchers = - RefCountedResource( - create = { key: Key -> - Multicaster( - scope = scope, - bufferSize = 0, - source = - flow { emitAll(realFetcher(key)) }.map { - when (it) { - is FetcherResult.Data -> { - try { - val network = it.value - val local = converter.fromNetworkToLocal(network) - sourceOfTruth?.write(key, local) - StoreReadResponse.Data( - network, - origin = StoreReadResponseOrigin.Fetcher(it.origin), - ) as StoreReadResponse - } catch (exception: CancellationException) { - throw exception - } catch (exception: Throwable) { - StoreReadResponse.Error.Exception( - exception, - origin = StoreReadResponseOrigin.Fetcher(it.origin), - ) - } - } - - is FetcherResult.Error.Message -> - StoreReadResponse.Error.Message( - it.message, - origin = StoreReadResponseOrigin.Fetcher(), - ) - - is FetcherResult.Error.Exception -> - StoreReadResponse.Error.Exception( - it.error, - origin = StoreReadResponseOrigin.Fetcher(), - ) - is FetcherResult.Error.Custom<*> -> - StoreReadResponse.Error.Custom( - it.error, - StoreReadResponseOrigin.Fetcher(), - ) - } - }.onEmpty { - val origin = - StoreReadResponseOrigin.Fetcher() - emit(StoreReadResponse.NoNewData(origin)) - }, - // When enabled, downstream collectors are never closed. - // Instead, they are kept active to receive values dispatched by fetchers created after them. - // This makes FetcherController act like a SourceOfTruth in the lack of a SourceOfTruth provided by the developer. - piggybackingDownstream = true, - onEach = { _ -> - // Exceptions thrown here propagate to the actor and close downstream channels silently. - // This caused store.stream() and store.get() to hang indefinitely (see #660). - // Consequently, we are intentionally performing no work here. - // Conversion and SOT writes now happen in the source flow above. - }, - ) - }, - onRelease = { _: Key, multicaster: Multicaster> -> - multicaster.close() - }, - ) - - fun getFetcher( - key: Key, - piggybackOnly: Boolean = false, - ): Flow> { - return flow { - val fetcher = acquireFetcher(key) - try { - emitAll(fetcher.newDownstream(piggybackOnly)) - } finally { - withContext(NonCancellable) { - fetchers.release(key, fetcher) - } - } - } - } - - /** - * This functions goes to great length to prevent capturing the calling context from - * [getFetcher]. The reason being that the [Flow] returned by [getFetcher] is collected on the - * user's context and [acquireFetcher] will, optionally, launch a long running coroutine on the - * [FetcherController]'s [scope]. In order to avoid capturing a reference to the scope we need - * to: - * 1) Not inline this function as that will cause the lambda to capture a reference to the - * surrounding suspend lambda which, in turn, holds a reference to the user's coroutine context. - * 2) Use [async]-[await] instead of - * [kotlinx.coroutines.withContext] as [kotlinx.coroutines.withContext] will also hold onto a - * reference to the caller's context (the LHS parameter of the new context which is used to run - * the operation). - */ - private suspend fun acquireFetcher(key: Key) = - scope.async { - fetchers.acquire(key) - }.await() - - // visible for testing - internal suspend fun fetcherSize() = fetchers.size() -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion.kt deleted file mode 100644 index 3971b847c..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/OnStoreWriteCompletion.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl - -import org.mobilenativefoundation.store.store5.StoreWriteResponse - -data class OnStoreWriteCompletion( - val onSuccess: (StoreWriteResponse.Success) -> Unit, - val onFailure: (StoreWriteResponse.Error) -> Unit, -) diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealBookkeeper.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealBookkeeper.kt deleted file mode 100644 index c0ff2dfef..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealBookkeeper.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl - -import org.mobilenativefoundation.store.store5.Bookkeeper -import org.mobilenativefoundation.store.store5.internal.definition.Timestamp - -internal class RealBookkeeper( - private val realGetLastFailedSync: suspend (key: Key) -> Timestamp?, - private val realSetLastFailedSync: suspend (key: Key, timestamp: Timestamp) -> Boolean, - private val realClear: suspend (key: Key) -> Boolean, - private val realClearAll: suspend () -> Boolean, -) : Bookkeeper { - override suspend fun getLastFailedSync(key: Key): Long? = realGetLastFailedSync(key) - - override suspend fun setLastFailedSync( - key: Key, - timestamp: Long, - ): Boolean = realSetLastFailedSync(key, timestamp) - - override suspend fun clear(key: Key): Boolean = realClear(key) - - override suspend fun clearAll(): Boolean = realClearAll() -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStore.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStore.kt deleted file mode 100644 index 83f7af843..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStore.kt +++ /dev/null @@ -1,347 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package org.mobilenativefoundation.store.store5.impl - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.Bookkeeper -import org.mobilenativefoundation.store.store5.Clear -import org.mobilenativefoundation.store.store5.Logger -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreWriteRequest -import org.mobilenativefoundation.store.store5.StoreWriteResponse -import org.mobilenativefoundation.store.store5.Updater -import org.mobilenativefoundation.store.store5.UpdaterResult -import org.mobilenativefoundation.store.store5.impl.extensions.now -import org.mobilenativefoundation.store.store5.internal.concurrent.ThreadSafety -import org.mobilenativefoundation.store.store5.internal.definition.WriteRequestQueue -import org.mobilenativefoundation.store.store5.internal.result.EagerConflictResolutionResult -import org.mobilenativefoundation.store.store5.internal.result.StoreDelegateWriteResult - -@OptIn(ExperimentalStoreApi::class) -internal class RealMutableStore( - private val delegate: RealStore, - private val updater: Updater, - private val bookkeeper: Bookkeeper?, - private val logger: Logger = DefaultLogger(), -) : MutableStore, Clear.Key by delegate, Clear.All by delegate { - private val storeLock = Mutex() - private val keyToWriteRequestQueue = mutableMapOf>() - private val keyToThreadSafety = mutableMapOf() - - override fun stream(request: StoreReadRequest): Flow> = - flow { - // Ensure we are ready for this key. - safeInitStore(request.key) - - // Try to eagerly resolve conflicts before pulling from network. - when (val eagerConflictResolutionResult = tryEagerlyResolveConflicts(request.key)) { - // TODO(#678): Many use cases will not want to pull immediately after failing to push local changes. - // We should enable configuration of conflict resolution strategies, such as logging, retrying, canceling. - - is EagerConflictResolutionResult.Error.Exception -> { - logger.error(eagerConflictResolutionResult.error.toString()) - } - - is EagerConflictResolutionResult.Error.Message -> { - logger.error(eagerConflictResolutionResult.message) - } - - is EagerConflictResolutionResult.Success.ConflictsResolved -> { - logger.debug(eagerConflictResolutionResult.value.toString()) - } - - EagerConflictResolutionResult.Success.NoConflicts -> { - logger.debug("No conflicts.") - } - } - - // Now, we can just delegate to the underlying stream. - delegate.stream(request).collect { storeReadResponse -> emit(storeReadResponse) } - } - - @ExperimentalStoreApi - override fun stream(requestStream: Flow>): Flow = - flow { - // Each incoming write request is enqueued. - // Then we try to update the network and delegate. - - requestStream - .onEach { writeRequest -> - // Prepare per-key data structures. - safeInitStore(writeRequest.key) - - // Enqueue the new write request. - addWriteRequestToQueue(writeRequest) - } - .collect { writeRequest -> - val storeWriteResponse = - try { - // Always write to local first. - // Only proceed to network if local write succeeded. - when (val delegateWriteResult = delegate.write(writeRequest.key, writeRequest.value)) { - is StoreDelegateWriteResult.Error.Exception -> { - StoreWriteResponse.Error.Exception(delegateWriteResult.error) - } - is StoreDelegateWriteResult.Error.Message -> { - StoreWriteResponse.Error.Message(delegateWriteResult.error) - } - is StoreDelegateWriteResult.Success -> { - // Try to sync to network. - when (val updaterResult = tryUpdateServer(writeRequest)) { - is UpdaterResult.Error.Exception -> StoreWriteResponse.Error.Exception(updaterResult.error) - is UpdaterResult.Error.Message -> StoreWriteResponse.Error.Message(updaterResult.message) - is UpdaterResult.Success.Typed<*> -> { - val typedValue = updaterResult.value as? Response - if (typedValue == null) { - StoreWriteResponse.Success.Untyped(updaterResult.value) - } else { - StoreWriteResponse.Success.Typed(updaterResult.value) - } - } - is UpdaterResult.Success.Untyped -> StoreWriteResponse.Success.Untyped(updaterResult.value) - } - } - } - } catch (throwable: Throwable) { - StoreWriteResponse.Error.Exception(throwable) - } - emit(storeWriteResponse) - } - } - - @ExperimentalStoreApi - override suspend fun write(request: StoreWriteRequest): StoreWriteResponse = - stream(flowOf(request)).first() - - private suspend fun tryUpdateServer(request: StoreWriteRequest): UpdaterResult { - val updaterResult = postLatest(request.key) - - if (updaterResult is UpdaterResult.Success) { - // We successfully synced to network, can now clear out any stale writes. - updateWriteRequestQueue( - key = request.key, - created = request.created, - updaterResult = updaterResult, - ) - bookkeeper?.clear(request.key) - } else { - // Could not sync, need to record a failed timestamp. - bookkeeper?.setLastFailedSync(request.key) - } - - return updaterResult - } - - /** - * Post the very latest write for [key] to the network using [updater]. - */ - private suspend fun postLatest(key: Key): UpdaterResult { - // The "latest" is the last item in the queue for this key. - val writer = getLatestWriteRequest(key) - - return when (val updaterResult = updater.post(key, writer.value)) { - is UpdaterResult.Error.Exception -> UpdaterResult.Error.Exception(updaterResult.error) - is UpdaterResult.Error.Message -> UpdaterResult.Error.Message(updaterResult.message) - is UpdaterResult.Success.Untyped -> UpdaterResult.Success.Untyped(updaterResult.value) - is UpdaterResult.Success.Typed<*> -> { - val typedValue = updaterResult.value as? Response - if (typedValue == null) { - UpdaterResult.Success.Untyped(updaterResult.value) - } else { - UpdaterResult.Success.Typed(updaterResult.value) - } - } - } - } - - /** - * Remove or keep queue items after a successful network sync. - */ - private suspend fun updateWriteRequestQueue( - key: Key, - created: Long, - updaterResult: UpdaterResult.Success, - ) { - val nextWriteRequestQueue = - withWriteRequestQueueLock(key) { - val remaining = ArrayDeque>() - - for (writeRequest in this) { - if (writeRequest.created <= created) { - // Mark each relevant request as succeeded. - updater.onCompletion?.onSuccess?.invoke(updaterResult) - - val storeWriteResponse = - when (updaterResult) { - is UpdaterResult.Success.Typed<*> -> { - val typedValue = updaterResult.value as? Response - if (typedValue == null) { - StoreWriteResponse.Success.Untyped(updaterResult.value) - } else { - StoreWriteResponse.Success.Typed(updaterResult.value) - } - } - - is UpdaterResult.Success.Untyped -> StoreWriteResponse.Success.Untyped(updaterResult.value) - } - - // Notify each on-completion callback. - writeRequest.onCompletions?.forEach { onStoreWriteCompletion -> - onStoreWriteCompletion.onSuccess(storeWriteResponse) - } - } else { - // Keep requests that happened after created. - remaining.add(writeRequest) - } - } - remaining - } - - // Update the in-memory map outside the queue's mutex. - storeLock.withLock { - keyToWriteRequestQueue[key] = nextWriteRequestQueue - } - } - - /** - * Locks the queue for [key] and invokes [block]. - */ - private suspend fun withWriteRequestQueueLock( - key: Key, - block: suspend WriteRequestQueue.() -> Result, - ): Result { - // Acquire the ThreadSafety object for this key without holding storeLock. - val threadSafety = getThreadSafety(key) - - // Exclusively lock the queue's own mutex. The block both reads and structurally mutates the - // per-key ArrayDeque (add / iterate-and-rebuild), so callers must mutually exclude each other. - // A shared/reader lock here would allow a concurrent add() during iteration, corrupting the - // deque's backing array — a ConcurrentModificationException on the JVM and an EXC_BAD_ACCESS - // on Kotlin/Native. - return threadSafety.writeRequests.mutex.withLock { - val queue = getQueue(key) - queue.block() - } - } - - private suspend fun getLatestWriteRequest(key: Key): StoreWriteRequest { - val threadSafety = getThreadSafety(key) - threadSafety.writeRequests.mutex.lock() - return try { - val queue = getQueue(key) - require(queue.isNotEmpty()) { - "No writes found for key=$key." - } - queue.last() - } finally { - threadSafety.writeRequests.mutex.unlock() - } - } - - /** - * Checks if we have un-synced writes or a recorded failed sync for [key]. - */ - private suspend fun conflictsMightExist(key: Key): Boolean { - val failed = bookkeeper?.getLastFailedSync(key) - return (failed != null) || !writeRequestsQueueIsEmpty(key) - } - - private fun writeRequestsQueueIsEmpty(key: Key): Boolean = keyToWriteRequestQueue[key].isNullOrEmpty() - - private suspend fun addWriteRequestToQueue(writeRequest: StoreWriteRequest) = - withWriteRequestQueueLock(writeRequest.key) { - add(writeRequest) - } - - private suspend fun tryEagerlyResolveConflicts(key: Key): EagerConflictResolutionResult { - // Acquire the ThreadSafety object for this key without holding storeLock. - val threadSafety = getThreadSafety(key) - - // Lock just long enough to check if conflicts exist. - val (latestValue, conflictsExist) = - threadSafety.readCompletions.mutex.withLock { - val latestValue = delegate.latestOrNull(key) - val conflictsExist = latestValue != null && bookkeeper != null && conflictsMightExist(key) - latestValue to conflictsExist - } - - return if (!conflictsExist || latestValue == null) { - EagerConflictResolutionResult.Success.NoConflicts - } else { - try { - val updaterResult = - updater.post(key, latestValue).also { updaterResult -> - if (updaterResult is UpdaterResult.Success) { - // If it succeeds, we want to remove stale requests and clear the bookkeeper. - updateWriteRequestQueue(key = key, created = now(), updaterResult = updaterResult) - - bookkeeper?.clear(key) - } - } - - when (updaterResult) { - is UpdaterResult.Error.Exception -> { - EagerConflictResolutionResult.Error.Exception(updaterResult.error) - } - - is UpdaterResult.Error.Message -> { - EagerConflictResolutionResult.Error.Message(updaterResult.message) - } - - is UpdaterResult.Success -> { - EagerConflictResolutionResult.Success.ConflictsResolved(updaterResult) - } - } - } catch (error: Throwable) { - EagerConflictResolutionResult.Error.Exception(error) - } - } - } - - /** - * Ensures that [keyToThreadSafety] and [keyToWriteRequestQueue] have entries for [key]. - * We only hold [storeLock] while touching these two maps, then release it immediately. - */ - private suspend fun safeInitStore(key: Key) { - storeLock.withLock { - if (keyToThreadSafety[key] == null) { - keyToThreadSafety[key] = ThreadSafety() - } - if (keyToWriteRequestQueue[key] == null) { - keyToWriteRequestQueue[key] = ArrayDeque() - } - } - } - - /** - * Retrieves the [ThreadSafety] object for [key] without reinitializing it, since [safeInitStore] handles creation. - * We do a quick [storeLock] read then release it without nesting per-key locks inside [storeLock]. - */ - private suspend fun getThreadSafety(key: Key): ThreadSafety { - return storeLock.withLock { - requireNotNull(keyToThreadSafety[key]) { - "ThreadSafety not initialized for key=$key." - } - } - } - - /** - * Helper to retrieve the queue for [key] without re-initialization logic. - */ - private suspend fun getQueue(key: Key): WriteRequestQueue { - return storeLock.withLock { - requireNotNull(keyToWriteRequestQueue[key]) { - "No write request queue found for key=$key." - } - } - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilder.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilder.kt deleted file mode 100644 index ff9696134..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealMutableStoreBuilder.kt +++ /dev/null @@ -1,109 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.GlobalScope -import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.cache5.CacheBuilder -import org.mobilenativefoundation.store.store5.Bookkeeper -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.MemoryPolicy -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.MutableStoreBuilder -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreDefaults -import org.mobilenativefoundation.store.store5.Updater -import org.mobilenativefoundation.store.store5.Validator -import org.mobilenativefoundation.store.store5.impl.extensions.asMutableStore - -// we don't have a source of truth and can use a dummy converter -fun mutableStoreBuilderFromFetcher( - fetcher: Fetcher, - converter: Converter, -): MutableStoreBuilder = RealMutableStoreBuilder(fetcher, converter = converter) - -fun mutableStoreBuilderFromFetcherAndSourceOfTruth( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - converter: Converter, -): MutableStoreBuilder = RealMutableStoreBuilder(fetcher, sourceOfTruth, converter = converter) - -fun mutableStoreBuilderFromFetcherSourceOfTruthAndMemoryCache( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - memoryCache: Cache, - converter: Converter, -): MutableStoreBuilder = RealMutableStoreBuilder(fetcher, sourceOfTruth, memoryCache, converter = converter) - -internal class RealMutableStoreBuilder( - private val fetcher: Fetcher, - private val sourceOfTruth: SourceOfTruth? = null, - private val memoryCache: Cache? = null, - private val converter: Converter, -) : MutableStoreBuilder { - private var scope: CoroutineScope? = null - private var cachePolicy: MemoryPolicy? = StoreDefaults.memoryPolicy - private var validator: Validator? = null - - override fun scope(scope: CoroutineScope): MutableStoreBuilder { - this.scope = scope - return this - } - - override fun cachePolicy(memoryPolicy: MemoryPolicy?): MutableStoreBuilder { - cachePolicy = memoryPolicy - return this - } - - override fun disableCache(): MutableStoreBuilder { - cachePolicy = null - return this - } - - override fun validator(validator: Validator): MutableStoreBuilder { - this.validator = validator - return this - } - - fun build(): Store = - RealStore( - scope = scope ?: GlobalScope, - sourceOfTruth = sourceOfTruth, - fetcher = fetcher, - converter = converter, - validator = validator, - memCache = - memoryCache ?: cachePolicy?.let { - CacheBuilder().apply { - if (cachePolicy!!.hasAccessPolicy) { - expireAfterAccess(cachePolicy!!.expireAfterAccess) - } - if (cachePolicy!!.hasWritePolicy) { - expireAfterWrite(cachePolicy!!.expireAfterWrite) - } - if (cachePolicy!!.hasMaxSize) { - maximumSize(cachePolicy!!.maxSize) - } - - if (cachePolicy!!.hasMaxWeight) { - weigher(cachePolicy!!.maxWeight) { key, value -> - cachePolicy!!.weigher.weigh( - key, - value, - ) - } - } - }.build() - }, - ) - - override fun build( - updater: Updater, - bookkeeper: Bookkeeper?, - ): MutableStore = - build().asMutableStore( - updater = updater, - bookkeeper = bookkeeper, - ) -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealSourceOfTruth.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealSourceOfTruth.kt deleted file mode 100644 index 2c50aa6b7..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealSourceOfTruth.kt +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.impl - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import org.mobilenativefoundation.store.store5.SourceOfTruth - -internal class PersistentSourceOfTruth( - private val realReader: (Key) -> Flow, - private val realWriter: suspend (Key, Local) -> Unit, - private val realDelete: (suspend (Key) -> Unit)? = null, - private val realDeleteAll: (suspend () -> Unit)? = null, -) : SourceOfTruth { - override fun reader(key: Key): Flow = realReader.invoke(key) - - override suspend fun write( - key: Key, - value: Local, - ) = realWriter(key, value) - - override suspend fun delete(key: Key) { - realDelete?.invoke(key) - } - - override suspend fun deleteAll() { - realDeleteAll?.invoke() - } -} - -internal class PersistentNonFlowingSourceOfTruth( - private val realReader: suspend (Key) -> Output?, - private val realWriter: suspend (Key, Local) -> Unit, - private val realDelete: (suspend (Key) -> Unit)? = null, - private val realDeleteAll: (suspend () -> Unit)?, -) : SourceOfTruth { - override fun reader(key: Key): Flow = - flow { - val sot = realReader(key) - emit(sot) - } - - override suspend fun write( - key: Key, - value: Local, - ) { - return realWriter(key, value) - } - - override suspend fun delete(key: Key) { - realDelete?.invoke(key) - } - - override suspend fun deleteAll() { - realDeleteAll?.invoke() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt deleted file mode 100644 index 632f339c8..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStore.kt +++ /dev/null @@ -1,376 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.impl - -import co.touchlab.kermit.CommonWriter -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.transform -import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.CacheType -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin -import org.mobilenativefoundation.store.store5.Validator -import org.mobilenativefoundation.store.store5.impl.operators.Either -import org.mobilenativefoundation.store.store5.impl.operators.merge -import org.mobilenativefoundation.store.store5.internal.result.StoreDelegateWriteResult - -internal class RealStore( - scope: CoroutineScope, - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth? = null, - private val converter: Converter, - private val validator: Validator?, - private val memCache: Cache?, -) : Store { - /** - * This source of truth is either a real database or an in memory source of truth created by - * the builder. - * Whatever is given, we always put a [SourceOfTruthWithBarrier] in front of it so that while - * we write the value from fetcher into the disk, we can block reads to avoid sending new data - * as if it came from the server (the [StoreReadResponse.origin] field). - */ - private val sourceOfTruth: SourceOfTruthWithBarrier? = - sourceOfTruth?.let { - SourceOfTruthWithBarrier(it, converter) - } - - /** - * Fetcher controller maintains 1 and only 1 `Multicaster` for a given key to ensure network - * requests are shared. - */ - private val fetcherController = - FetcherController( - scope = scope, - realFetcher = fetcher, - sourceOfTruth = this.sourceOfTruth, - converter = converter, - ) - - @Suppress("UNCHECKED_CAST") - override fun stream(request: StoreReadRequest): Flow> = - flow { - val cachedToEmit = - if (request.shouldSkipCache(CacheType.MEMORY)) { - null - } else { - val output: Output? = memCache?.getIfPresent(request.key) - val isInvalid = output != null && validator?.isValid(output) == false - when { - output == null || isInvalid -> null - else -> output - } - } - - cachedToEmit?.let { it: Output -> - // if we read a value from cache, dispatch it first - emit(StoreReadResponse.Data(value = it, origin = StoreReadResponseOrigin.Cache)) - } - - if (sourceOfTruth == null && !request.fetch) { - if (memCache == null) { - logger.w("Local-only request made with no cache or source of truth configured") - } - emit(StoreReadResponse.NoNewData(origin = StoreReadResponseOrigin.Cache)) - return@flow - } - - val stream: Flow> = - if (sourceOfTruth == null) { - // piggyback only if not specified fresh data AND we emitted a value from the cache - val piggybackOnly = !request.refresh && cachedToEmit != null - @Suppress("UNCHECKED_CAST") - - createNetworkFlow( - request = request, - networkLock = null, - piggybackOnly = piggybackOnly, - ) as Flow> // when no source of truth Input == Output - } else if (request.fetch) { - diskNetworkCombined(request, sourceOfTruth) - } else { - val diskLock = CompletableDeferred() - diskLock.complete(Unit) - sourceOfTruth.reader(request.key, diskLock).transform { response -> - val data = response.dataOrNull() - if (data == null || validator?.isValid(data) == false) { - emit(StoreReadResponse.NoNewData(origin = response.origin)) - } else { - emit(StoreReadResponse.Data(value = data, origin = response.origin)) - } - } - } - emitAll( - stream.transform { output: StoreReadResponse -> - emit(output) - if (output is StoreReadResponse.NoNewData && cachedToEmit == null) { - // In the special case where fetcher returned no new data we actually want to - // serve cache data (even if the request specified skipping cache and/or SoT) - // - // For stream(Request.cached(key, refresh=true)) we will return: - // Cache - // Source of truth - // Fetcher - > Loading - // Fetcher - > NoNewData - // (future Source of truth updates) - // - // For stream(Request.fresh(key)) we will return: - // Fetcher - > Loading - // Fetcher - > NoNewData - // Cache - // Source of truth - // (future Source of truth updates) - memCache?.getIfPresent(request.key)?.let { - emit( - StoreReadResponse.Data( - value = it, - origin = StoreReadResponseOrigin.Cache, - ), - ) - } - } - }, - ) - }.onEach { - // whenever a value is dispatched, save it to the memory cache - if (it.origin != StoreReadResponseOrigin.Cache) { - it.dataOrNull()?.let { data -> - memCache?.put(request.key, data) - } - } - } - - override suspend fun clear(key: Key) { - memCache?.invalidate(key) - sourceOfTruth?.delete(key) - } - - @ExperimentalStoreApi - override suspend fun clear() { - memCache?.invalidateAll() - sourceOfTruth?.deleteAll() - } - - /** - * We want to stream from disk but also want to refresh. If requested or necessary. - * - * How it works: - * There are two flows: - * Fetcher: The flow we get for the fetching - * Disk: The flow we get from the [SourceOfTruth]. - * Both flows are controlled by a lock for each so that we can start the right one based on - * the request status or values we receive. - * - * Value is always returned from [SourceOfTruth] while the errors are dispatched from both the - * `Fetcher` and [SourceOfTruth]. - * - * There are two initialization paths: - * - * 1) Request wants to skip disk cache: - * In this case, we first start the fetcher flow. When fetcher flow provides something besides - * an error, we enable the disk flow. - * - * 2) Request does not want to skip disk cache: - * In this case, we first start the disk flow. If disk flow returns `null` or - * [StoreReadRequest.refresh] is set to `true`, we enable the fetcher flow. - * This ensures we first get the value from disk and then load from server if necessary. - */ - private fun diskNetworkCombined( - request: StoreReadRequest, - sourceOfTruth: SourceOfTruthWithBarrier, - ): Flow> { - val diskLock = CompletableDeferred() - val networkLock = CompletableDeferred() - val networkFlow = createNetworkFlow(request, networkLock) - val skipDiskCache = request.shouldSkipCache(CacheType.DISK) - if (!skipDiskCache) { - diskLock.complete(Unit) - } - val diskFlow = - sourceOfTruth.reader(request.key, diskLock).onStart { - // wait for disk to latch first to ensure it happens before network triggers. - // after that, if we'll not read from disk, then allow network to continue - if (skipDiskCache) { - networkLock.complete(Unit) - } - } - - val requestKeyToFetcherName: MutableMap = mutableMapOf() - // Track if network errored AND this is a fresh request where fallback behavior matters - var networkErrorWithNoFallback = false - // we use a merge implementation that gives the source of the flow so that we can decide - // based on that. - return networkFlow.merge(diskFlow).transform { - // left is Fetcher while right is source of truth - when (it) { - is Either.Left -> { - // left, that is data from network - val responseOrigin = it.value.origin as StoreReadResponseOrigin.Fetcher - requestKeyToFetcherName[request.key] = responseOrigin.name - - // Track if network errored and fallback to disk is disabled for fresh requests - if (it.value is StoreReadResponse.Error && skipDiskCache && !request.fallBackToSourceOfTruth) { - networkErrorWithNoFallback = true - } else if (it.value is StoreReadResponse.Data || it.value is StoreReadResponse.NoNewData) { - // Reset on success so subsequent SOT emissions aren't incorrectly filtered - networkErrorWithNoFallback = false - } - - if (it.value is StoreReadResponse.Data || - it.value is StoreReadResponse.NoNewData || - it.value is StoreReadResponse.Error - ) { - // Unlocking disk only if network sent data, reported no new data, or returned an error - // so that fresh data request never receives new fetcher data after - // cached disk data, and so that the flow can properly complete on errors. - // This means that if the user asked for fresh data but the network returned - // no new data we will still unblock disk. - diskLock.complete(Unit) - } - - if (it.value !is StoreReadResponse.Data) { - emit(it.value.swapType()) - } - } - - is Either.Right -> { - // right, that is data from disk - when (val diskData = it.value) { - is StoreReadResponse.Data -> { - // Skip disk data (SOT origin) if this was a fresh request that errored with fallback disabled. - // But always emit fresh network data (Fetcher origin) even after prior errors. - if (networkErrorWithNoFallback && diskData.origin !is StoreReadResponseOrigin.Fetcher) { - return@transform - } - - val responseOriginWithFetcherName = - diskData.origin.let { origin -> - if (origin is StoreReadResponseOrigin.Fetcher) { - origin.copy(name = requestKeyToFetcherName[request.key]) - } else { - origin - } - } - - val diskValue = diskData.value - val isValid = - (validator == null && diskValue != null) || - diskData.origin is StoreReadResponseOrigin.Fetcher || - (diskValue != null && validator?.isValid(diskValue) ?: true) - - if (isValid) { - @Suppress("UNCHECKED_CAST") - val output = - diskData.copy(origin = responseOriginWithFetcherName) as StoreReadResponse - emit(output) - } - // If the disk value is null - // or refresh was requested - // or the disk value is not valid - // then allow fetcher to start emitting values. - if (request.refresh || diskData.value == null || !isValid) { - networkLock.complete(Unit) - } - } - - is StoreReadResponse.Error -> { - // disk sent an error, send it down as well - emit(diskData) - - // If disk sent a read error, we should allow fetcher to start emitting - // values since there is nothing to read from disk. If disk sent a write - // error, we should NOT allow fetcher to start emitting values as we - // should always wait for the read attempt. - if (diskData is StoreReadResponse.Error.Exception && - diskData.error is SourceOfTruth.ReadException - ) { - networkLock.complete(Unit) - } - // for other errors, don't do anything, wait for the read attempt - } - - is StoreReadResponse.Initial, - is StoreReadResponse.Loading, - is StoreReadResponse.NoNewData, - -> { - } - } - } - } - } - } - - private fun createNetworkFlow( - request: StoreReadRequest, - networkLock: CompletableDeferred?, - piggybackOnly: Boolean = false, - ): Flow> { - return fetcherController - .getFetcher(request.key, piggybackOnly) - .onStart { - // wait until disk gives us the go - networkLock?.await() - if (!piggybackOnly) { - emit(StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher())) - } - } - } - - internal suspend fun write( - key: Key, - value: Output, - ): StoreDelegateWriteResult = - try { - val writeException = sourceOfTruth?.write(key, converter.fromOutputToLocal(value)) - if (writeException != null) { - StoreDelegateWriteResult.Error.Exception(writeException) - } else { - memCache?.put(key, value) - StoreDelegateWriteResult.Success - } - } catch (error: Throwable) { - StoreDelegateWriteResult.Error.Exception(error) - } - - internal suspend fun latestOrNull(key: Key): Output? = fromMemCache(key) ?: fromSourceOfTruth(key) - - private suspend fun fromSourceOfTruth(key: Key) = - sourceOfTruth?.reader(key, CompletableDeferred(Unit))?.map { it.dataOrNull() }?.first() - - private fun fromMemCache(key: Key) = memCache?.getIfPresent(key) - - companion object { - private val logger = - Logger.apply { - setLogWriters(listOf(CommonWriter())) - setTag("Store") - } - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreBuilder.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreBuilder.kt deleted file mode 100644 index 88b3d7667..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreBuilder.kt +++ /dev/null @@ -1,145 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package org.mobilenativefoundation.store.store5.impl - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.cache5.CacheBuilder -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.MemoryPolicy -import org.mobilenativefoundation.store.store5.MutableStoreBuilder -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreBuilder -import org.mobilenativefoundation.store.store5.StoreDefaults -import org.mobilenativefoundation.store.store5.Validator - -fun storeBuilderFromFetcher( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth? = null, -): StoreBuilder = RealStoreBuilder(fetcher, sourceOfTruth) - -fun storeBuilderFromFetcherAndSourceOfTruth( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, -): StoreBuilder = RealStoreBuilder(fetcher, sourceOfTruth) - -fun storeBuilderFromFetcherSourceOfTruthAndMemoryCache( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - memoryCache: Cache, -): StoreBuilder = RealStoreBuilder(fetcher, sourceOfTruth, memoryCache) - -fun storeBuilderFromFetcherSourceOfTruthMemoryCacheAndConverter( - fetcher: Fetcher, - sourceOfTruth: SourceOfTruth, - memoryCache: Cache?, - converter: Converter, -): StoreBuilder = RealStoreBuilder(fetcher, sourceOfTruth, memoryCache, converter) - -internal class RealStoreBuilder( - private val fetcher: Fetcher, - private val sourceOfTruth: SourceOfTruth? = null, - private val memoryCache: Cache? = null, - private val converter: Converter? = null, -) : StoreBuilder { - private var scope: CoroutineScope? = null - private var cachePolicy: MemoryPolicy? = StoreDefaults.memoryPolicy - private var validator: Validator? = null - - override fun scope(scope: CoroutineScope): StoreBuilder { - this.scope = scope - return this - } - - override fun cachePolicy(memoryPolicy: MemoryPolicy?): StoreBuilder { - cachePolicy = memoryPolicy - return this - } - - override fun disableCache(): StoreBuilder { - cachePolicy = null - return this - } - - override fun validator(validator: Validator): StoreBuilder { - this.validator = validator - return this - } - - @OptIn(DelicateCoroutinesApi::class) - override fun build(): Store = - RealStore( - scope = scope ?: GlobalScope, - sourceOfTruth = sourceOfTruth, - fetcher = fetcher, - converter = converter ?: DefaultConverter(), - validator = validator, - memCache = - memoryCache ?: cachePolicy?.let { - CacheBuilder().apply { - if (cachePolicy!!.hasAccessPolicy) { - expireAfterAccess(cachePolicy!!.expireAfterAccess) - } - if (cachePolicy!!.hasWritePolicy) { - expireAfterWrite(cachePolicy!!.expireAfterWrite) - } - if (cachePolicy!!.hasMaxSize) { - maximumSize(cachePolicy!!.maxSize) - } - - if (cachePolicy!!.hasMaxWeight) { - weigher(cachePolicy!!.maxWeight) { key, value -> - cachePolicy!!.weigher.weigh( - key, - value, - ) - } - } - }.build() - }, - ) - - override fun toMutableStoreBuilder( - converter: Converter, - ): MutableStoreBuilder { - fetcher as Fetcher - return if (sourceOfTruth == null && memoryCache == null) { - mutableStoreBuilderFromFetcher(fetcher, converter) - } else if (memoryCache == null) { - mutableStoreBuilderFromFetcherAndSourceOfTruth( - fetcher, - sourceOfTruth as SourceOfTruth, - converter, - ) - } else { - mutableStoreBuilderFromFetcherSourceOfTruthAndMemoryCache( - fetcher, - sourceOfTruth as SourceOfTruth, - memoryCache, - converter, - ) - }.apply { - if (this@RealStoreBuilder.scope != null) { - scope(this@RealStoreBuilder.scope!!) - } - - if (this@RealStoreBuilder.cachePolicy != null) { - cachePolicy(this@RealStoreBuilder.cachePolicy) - } - - if (this@RealStoreBuilder.validator != null) { - validator(this@RealStoreBuilder.validator!!) - } - } - } -} - -private class DefaultConverter : Converter { - override fun fromOutputToLocal(output: Output): Local = throw IllegalStateException("non mutable store never call this function") - - override fun fromNetworkToLocal(network: Network): Local = network as Local -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest.kt deleted file mode 100644 index bbf87fc08..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealStoreWriteRequest.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl - -import org.mobilenativefoundation.store.store5.StoreWriteRequest - -data class RealStoreWriteRequest( - override val key: Key, - override val value: Output, - override val created: Long, - override val onCompletions: List?, -) : StoreWriteRequest diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealValidator.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealValidator.kt deleted file mode 100644 index 4c956a924..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RealValidator.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl - -import org.mobilenativefoundation.store.store5.Validator - -internal class RealValidator( - private val realValidator: suspend (item: Output) -> Boolean, -) : Validator { - override suspend fun isValid(item: Output): Boolean = realValidator(item) -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RefCountedResource.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RefCountedResource.kt deleted file mode 100644 index d98473292..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/RefCountedResource.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.impl - -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -/** - * Simple holder that can ref-count items by a given key. - */ -internal class RefCountedResource( - private val create: suspend (Key) -> T, - private val onRelease: (suspend (Key, T) -> Unit)? = null, -) { - private val items = mutableMapOf() - private val lock = Mutex() - - suspend fun acquire(key: Key): T = - lock.withLock { - items.getOrPut(key) { - Item(create(key)) - }.also { - it.refCount++ - }.value - } - - suspend fun release( - key: Key, - value: T, - ) = lock.withLock { - val existing = items[key] - check(existing != null && existing.value === value) { - "inconsistent release, seems like $value was leaked or never acquired" - } - existing.refCount-- - if (existing.refCount < 1) { - items.remove(key) - onRelease?.invoke(key, value) - } - } - - // used in tests - suspend fun size() = - lock.withLock { - items.size - } - - private inner class Item( - val value: T, - var refCount: Int = 0, - ) -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/SourceOfTruthWithBarrier.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/SourceOfTruthWithBarrier.kt deleted file mode 100644 index d791757a7..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/SourceOfTruthWithBarrier.kt +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.impl - -import kotlinx.atomicfu.atomic -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.onStart -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreReadResponseOrigin -import org.mobilenativefoundation.store.store5.impl.operators.mapIndexed - -/** - * Wraps a [SourceOfTruth] and blocks reads while a write is in progress. - * - * Used in the [RealStore] implementation to avoid - * dispatching values to downstream while a write is in progress. - */ -@Suppress("UNCHECKED_CAST") -internal class SourceOfTruthWithBarrier( - private val delegate: SourceOfTruth, - private val converter: Converter? = null, -) { - /** - * Each key has a barrier so that we can block reads while writing. - */ - private val barriers = - RefCountedResource>( - create = { - MutableStateFlow(BarrierMsg.Open.INITIAL) - }, - ) - - /** - * Each message gets dispatched with a version. This ensures we won't accidentally turn on the - * reader flow for a new reader that happens to have arrived while a write is in progress since - * that write should be considered as a disk read for that flow, not fetcher. - */ - private val versionCounter = atomic(0L) - - fun reader( - key: Key, - lock: CompletableDeferred, - ): Flow> { - return flow { - val barrier = barriers.acquire(key) - val readerVersion: Long = versionCounter.incrementAndGet() - try { - lock.await() - emitAll( - barrier - .flatMapLatest { barrierMessage -> - val messageArrivedAfterMe = readerVersion < barrierMessage.version - val writeError = - if (messageArrivedAfterMe && barrierMessage is BarrierMsg.Open) { - barrierMessage.writeError - } else { - null - } - val readFlow: Flow> = - when (barrierMessage) { - is BarrierMsg.Open -> - delegate.reader(key).mapIndexed { index, local: Output? -> - if (index == 0 && messageArrivedAfterMe) { - val firstMsgOrigin = - if (writeError == null) { - // restarted barrier without an error means write succeeded - StoreReadResponseOrigin.Fetcher() - } else { - // when a write fails, we still get a new reader because - // we've disabled the previous reader before starting the - // write operation. But since write has failed, we should - // use the SourceOfTruth as the origin - StoreReadResponseOrigin.SourceOfTruth - } - StoreReadResponse.Data( - origin = firstMsgOrigin, - value = local, - ) - } else { - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = local, - ) as StoreReadResponse - } - }.catch { throwable -> - this.emit( - StoreReadResponse.Error.Exception( - error = - SourceOfTruth.ReadException( - key = key, - cause = throwable.cause ?: throwable, - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - ) - } - - is BarrierMsg.Blocked -> { - flowOf() - } - } - readFlow - .onStart { - // if we have a pending error, make sure to dispatch it first. - if (writeError != null) { - emit( - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = writeError, - ), - ) - } - } - }, - ) - } finally { - // we are using a finally here instead of onCompletion as there might be a - // possibility where flow gets cancelled right before `emitAll`. - barriers.release(key, barrier) - } - } - } - - /** - * Writes a value to the underlying [SourceOfTruth] and returns any error that occurred. - * - * @return The [SourceOfTruth.WriteException] if the write failed, or null if successful. - * Callers like [RealStore.write] can check this to determine if the write succeeded. - * The barrier mechanism also notifies readers of the error via [BarrierMsg.Open.writeError]. - */ - @Suppress("UNCHECKED_CAST") - suspend fun write( - key: Key, - value: Local, - ): SourceOfTruth.WriteException? { - val barrier = barriers.acquire(key) - try { - barrier.emit(BarrierMsg.Blocked(versionCounter.incrementAndGet())) - val writeError = - try { - delegate.write(key, value) - null - } catch (throwable: Throwable) { - if (throwable !is CancellationException) { - throwable - } else { - null - } - } - - // Avoid double-wrapping if the error is already a WriteException. - val writeException = - writeError?.let { - writeError as? SourceOfTruth.WriteException - ?: SourceOfTruth.WriteException( - key = key, - value = value, - cause = writeError, - ) - } - - barrier.emit( - BarrierMsg.Open( - version = versionCounter.incrementAndGet(), - writeError = writeException, - ), - ) - - // Return the error so callers know the operation failed. - // The barrier message above notifies readers of the error. - return writeException - } finally { - barriers.release(key, barrier) - } - } - - suspend fun delete(key: Key) { - delegate.delete(key) - } - - suspend fun deleteAll() { - delegate.deleteAll() - } - - private sealed class BarrierMsg( - val version: Long, - ) { - class Blocked(version: Long) : BarrierMsg(version) - - class Open(version: Long, val writeError: Throwable? = null) : BarrierMsg(version) { - companion object { - val INITIAL = Open(INITIAL_VERSION) - } - } - } - - // visible for testing - internal suspend fun barrierCount() = barriers.size() - - companion object { - private const val INITIAL_VERSION = -1L - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.kt deleted file mode 100644 index c2d33d4fb..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -import kotlin.time.Duration.Companion.hours - -internal expect fun currentTimeMillis(): Long - -internal fun now() = currentTimeMillis() - -internal fun inHours(n: Int) = currentTimeMillis() + n.hours.inWholeMilliseconds diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt deleted file mode 100644 index cdf0910c7..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/store.kt +++ /dev/null @@ -1,156 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.first -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.Bookkeeper -import org.mobilenativefoundation.store.store5.MutableStore -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.Updater -import org.mobilenativefoundation.store.store5.impl.RealMutableStore -import org.mobilenativefoundation.store.store5.impl.RealStore - -/** - * Helper factory that will return data for [key] if it is cached otherwise will return - * fresh/network data (updating your caches) - * - * Note: Exceptions will not be handled within this function. - * - * ``` - * try { - * store.get(Key.Read.All) - * } catch (e: Exception) { - * // handle exception - * } - * ``` - * - * @param Key The key to get cached data for. - * @param Output The common representation of the data. - */ -suspend fun Store.get(key: Key) = - stream(StoreReadRequest.cached(key, refresh = false)) - .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } - .first() - .requireData() - -/** - * Helper factory that will return fresh data for [key] while updating your caches - * - * If the [Fetcher] does not return any data (i.e the returned - * [kotlinx.coroutines.flow.Flow], when collected, is empty). Then store will fall back to local - * data **even** if you explicitly requested fresh data. - * See https://github.com/dropbox/Store/pull/194 for context - * - * Note: Exceptions will not be handled within this function. - * - * ``` - * try { - * store.fresh(Key.Read.All) - * } catch (e: Exception) { - * // handle exception - * } - * ``` - * - * @param Key The key to fetch fresh data for. - * @param Output The common representation of the data. - * @return The fresh data associated with the key. - */ -suspend fun Store.fresh(key: Key) = - stream(StoreReadRequest.fresh(key)) - .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } - .first() - .requireData() - -/** - * Extension function to convert a [Store] into a [MutableStore]. - * - * This function allows a [Store] to be used as a [MutableStore] by providing an [Updater] and an - * optional [Bookkeeper]. - * - * ``` - * store.asMutableStore(updater, bookkeeper) - * ``` - * - * @param Key The type of the key used to get data. - * @param Network The type of data returned by the fetcher - * @param Output The common representation of the data. - * @param Local The type of the data used by the source of truth. - * @param Response The updater result write response type. - * @param updater Posts data to remote data source. - * @param bookkeeper Optionally used to track when local changes fail to sync with network. - * @return A [MutableStore] instance. - * @throws Exception if the [Store] is not built using [StoreBuilder]. - */ -@OptIn(ExperimentalStoreApi::class) -@Suppress("UNCHECKED_CAST") -fun Store.asMutableStore( - updater: Updater, - bookkeeper: Bookkeeper?, -): MutableStore { - val delegate = - this as? RealStore - ?: throw Exception("MutableStore requires Store to be built using StoreBuilder") - - return RealMutableStore( - delegate = delegate, - updater = updater, - bookkeeper = bookkeeper, - ) -} - -/** - * Helper function that returns data for the given [key] if it is cached, otherwise it will return - * fresh/network data (updating your caches). - * - * Note: Exceptions will not be handled within this function. - * - * ``` - * try { - * store.get(Key.Read.All) - * } catch (e: Exception) { - * // handle exception - * } - * ``` - * - * @param Key The key to get cached data for. - * @param Output The common representation of the data. - * @param Response The updater result write response type. - * @return The data associated with the key. - */ -@OptIn(ExperimentalStoreApi::class) -suspend fun MutableStore.get(key: Key) = - stream(StoreReadRequest.cached(key, refresh = false)) - .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } - .first() - .requireData() - -/** - * Helper function that returns fresh data for the given [key] while updating your caches. - * - * If the [Fetcher] does not return any data (i.e., the returned [kotlinx.coroutines.flow.Flow], - * when collected, is empty), then the store will fall back to local data even if you explicitly - * requested fresh data. - * - * Note: Exceptions will not be handled within this function. - * - * ``` - * try { - * store.fresh(Key.Read.All) - * } catch (e: Exception) { - * // handle exception - * } - * ``` - * - * @param Key The key to fetch fresh data for. - * @param Output The common representation of the data. - * @param Response The updater result write response type. - * @return The fresh data associated with the key. - */ -@OptIn(ExperimentalStoreApi::class) -suspend fun MutableStore.fresh(key: Key) = - stream(StoreReadRequest.fresh(key)) - .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } - .first() - .requireData() diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/FlowMerge.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/FlowMerge.kt deleted file mode 100644 index 09d4f4cf9..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/FlowMerge.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.impl.operators - -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.buffer -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.launch - -/** - * Merge implementation tells downstream what the source is and also uses a rendezvous channel - */ -internal fun Flow.merge(other: Flow): Flow> { - return channelFlow> { - launch { - this@merge.collect { - send(Either.Left(it)) - } - } - launch { - other.collect { - send(Either.Right(it)) - } - } - }.buffer(Channel.RENDEZVOUS) -} - -internal sealed class Either { - data class Left(val value: T) : Either() - - data class Right(val value: R) : Either() -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/MapIndexed.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/MapIndexed.kt deleted file mode 100644 index c3644cd0c..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/impl/operators/MapIndexed.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.impl.operators - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectIndexed -import kotlinx.coroutines.flow.flow - -internal inline fun Flow.mapIndexed(crossinline block: (Int, T) -> R) = - flow { - collectIndexed { index, value -> - emit(block(index, value)) - } - } diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/ThreadSafety.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/ThreadSafety.kt deleted file mode 100644 index 28e04a706..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/concurrent/ThreadSafety.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.mobilenativefoundation.store.store5.internal.concurrent - -import kotlinx.coroutines.sync.Mutex - -internal data class ThreadSafety( - val writeRequests: StoreThreadSafety = StoreThreadSafety(), - val readCompletions: StoreThreadSafety = StoreThreadSafety(), -) - -internal data class StoreThreadSafety( - val mutex: Mutex = Mutex(), -) diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/Timestamp.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/Timestamp.kt deleted file mode 100644 index 82019ad80..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/Timestamp.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.mobilenativefoundation.store.store5.internal.definition - -typealias Timestamp = Long diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/WriteRequestQueue.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/WriteRequestQueue.kt deleted file mode 100644 index eef08167e..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/definition/WriteRequestQueue.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.mobilenativefoundation.store.store5.internal.definition - -import org.mobilenativefoundation.store.store5.StoreWriteRequest - -typealias WriteRequestQueue = ArrayDeque> diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult.kt deleted file mode 100644 index 3f49c674e..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/EagerConflictResolutionResult.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.mobilenativefoundation.store.store5.internal.result - -import org.mobilenativefoundation.store.store5.UpdaterResult - -sealed class EagerConflictResolutionResult { - sealed class Success : EagerConflictResolutionResult() { - object NoConflicts : Success() - - data class ConflictsResolved(val value: UpdaterResult.Success) : Success() - } - - sealed class Error : EagerConflictResolutionResult() { - data class Message(val message: String) : Error() - - data class Exception(val error: Throwable) : Error() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult.kt b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult.kt deleted file mode 100644 index 3a760e750..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/internal/result/StoreDelegateWriteResult.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.mobilenativefoundation.store.store5.internal.result - -sealed class StoreDelegateWriteResult { - object Success : StoreDelegateWriteResult() - - sealed class Error : StoreDelegateWriteResult() { - data class Message(val error: String) : Error() - - data class Exception(val error: Throwable) : Error() - } -} diff --git a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/storeBuilder.uml b/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/storeBuilder.uml deleted file mode 100644 index 87f1d7f4b..000000000 --- a/store/src/commonMain/kotlin/org/mobilenativefoundation/store/store5/storeBuilder.uml +++ /dev/null @@ -1,19 +0,0 @@ - - - JAVA - - - - - - - - Fields - Constructors - Methods - Properties - Inner Classes - - All - public - \ No newline at end of file diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearAllStoreTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearAllStoreTests.kt deleted file mode 100644 index a871a0665..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearAllStoreTests.kt +++ /dev/null @@ -1,180 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.util.InMemoryPersister -import org.mobilenativefoundation.store.store5.util.asSourceOfTruth -import org.mobilenativefoundation.store.store5.util.getData -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -@FlowPreview -@ExperimentalCoroutinesApi -@ExperimentalStoreApi -class ClearAllStoreTests { - private val testScope = TestScope() - - private val key1 = "key1" - private val key2 = "key2" - private val value1 = 1 - private val value2 = 2 - - private lateinit var fetcher: Fetcher - - private lateinit var persister: InMemoryPersister - - @BeforeTest - fun before() { - persister = InMemoryPersister() - fetcher = - Fetcher.of { key: String -> - when (key) { - key1 -> value1 - key2 -> value2 - else -> throw IllegalStateException("Unknown key") - } - } - } - - @Test - fun callingClearAllOnStoreWithPersisterAndNoInMemoryCacheDeletesAllEntriesFromThePersister() = - testScope.runTest { - val store = - StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ).scope(testScope) - .disableCache() - .build() - - // should receive data from network first time - val responseOneA = store.getData(key1) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value1, - ), - responseOneA, - ) - val responseTwoA = store.getData(key2) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value2, - ), - responseTwoA, - ) - // should receive data from persister - val responseOneB = store.getData(key1) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = value1, - ), - responseOneB, - ) - val responseTwoB = store.getData(key2) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = value2, - ), - responseTwoB, - ) - // clear all entries in store - store.clear() - assertNull(persister.peekEntry(key1)) - assertNull(persister.peekEntry(key2)) - - // should fetch data from network again - val responseOneC = store.getData(key1) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value1, - ), - responseOneC, - ) - - val responseTwoC = store.getData(key2) - advanceUntilIdle() - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value2, - ), - responseTwoC, - ) - } - - @Test - fun callingClearAllOnStoreWithInMemoryCacheAndNoPersisterDeletesAllEntriesFromTheInMemoryCache() = - testScope.runTest { - val store = - StoreBuilder.from( - fetcher = fetcher, - ).scope(testScope).build() - - // should receive data from network first time - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value1, - ), - store.getData(key1), - ) - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value2, - ), - store.getData(key2), - ) - - // should receive data from cache - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Cache, - value = value1, - ), - store.getData(key1), - ) - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Cache, - value = value2, - ), - store.getData(key2), - ) - - // clear all entries in store - store.clear() - - // should fetch data from network again - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value1, - ), - store.getData(key1), - ) - assertEquals( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value2, - ), - store.getData(key2), - ) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearStoreByKeyTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearStoreByKeyTests.kt deleted file mode 100644 index e08556b44..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ClearStoreByKeyTests.kt +++ /dev/null @@ -1,159 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.StoreReadResponse.Data -import org.mobilenativefoundation.store.store5.util.InMemoryPersister -import org.mobilenativefoundation.store.store5.util.asSourceOfTruth -import org.mobilenativefoundation.store.store5.util.getData -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -@FlowPreview -@ExperimentalCoroutinesApi -class ClearStoreByKeyTests { - private val testScope = TestScope() - - private val persister = InMemoryPersister() - - @Test - fun callingClearWithKeyOnStoreWithPersisterWithNoInMemoryCacheDeletesTheEntryAssociatedWithTheKeyFromThePersister() = - testScope.runTest { - val key = "key" - val value = 1 - val store = - StoreBuilder.from( - fetcher = Fetcher.of { value }, - sourceOfTruth = persister.asSourceOfTruth(), - ).scope(testScope) - .disableCache() - .build() - - // should receive data from network first time - assertEquals( - Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value, - ), - store.getData(key), - ) - - // should receive data from persister - assertEquals( - Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = value, - ), - store.getData(key), - ) - - // clear store entry by key - store.clear(key) - assertNull(persister.peekEntry(key)) - // should fetch data from network again - assertEquals( - Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value, - ), - store.getData(key), - ) - } - - @Test - fun callingClearWithKeyOStoreWithInMemoryCacheNoPersisterDeletesTheEntryAssociatedWithTheKeyFromTheInMemoryCache() = - testScope.runTest { - val key = "key" - val value = 1 - val store = - StoreBuilder.from( - fetcher = Fetcher.of { value }, - ).scope(testScope).build() - - // should receive data from network first time - assertEquals( - Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value, - ), - store.getData(key), - ) - - // should receive data from cache - assertEquals( - Data( - origin = StoreReadResponseOrigin.Cache, - value = value, - ), - store.getData(key), - ) - - // clear store entry by key - store.clear(key) - - // should fetch data from network again - assertEquals( - Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value, - ), - store.getData(key), - ) - } - - @Test - fun callingClearWithKeyOnStoreHasNoEffectOnExistingEntriesAssociatedWithOtherKeysInTheInMemoryCacheOrPersister() = - testScope.runTest { - val key1 = "key1" - val key2 = "key2" - val value1 = 1 - val value2 = 2 - val store = - StoreBuilder.from( - fetcher = - Fetcher.of { key -> - when (key) { - key1 -> value1 - key2 -> value2 - else -> throw IllegalStateException("Unknown key") - } - }, - sourceOfTruth = persister.asSourceOfTruth(), - ).scope(testScope) - .build() - - // get data for both keys - store.getData(key1) - store.getData(key2) - - // clear store entry for key1 - store.clear(key1) - - // entry for key1 is gone - assertNull(persister.peekEntry(key1)) - - // entry for key2 should still exists - assertEquals(value2, persister.peekEntry(key2)) - - // getting data for key1 should hit the network again - assertEquals( - Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = value1, - ), - store.getData(key1), - ) - - // getting data for key2 should not hit the network - assertEquals( - Data( - origin = StoreReadResponseOrigin.Cache, - value = value2, - ), - store.getData(key2), - ) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FallbackTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FallbackTests.kt deleted file mode 100644 index d18cc79db..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FallbackTests.kt +++ /dev/null @@ -1,149 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.util.fake.fallback.HardcodedPages -import org.mobilenativefoundation.store.store5.util.fake.fallback.Page -import org.mobilenativefoundation.store.store5.util.fake.fallback.PagesDatabase -import org.mobilenativefoundation.store.store5.util.fake.fallback.PrimaryPagesApi -import org.mobilenativefoundation.store.store5.util.fake.fallback.SecondaryPagesApi -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -class FallbackTests { - private val testScope = TestScope() - private lateinit var api: PrimaryPagesApi - private lateinit var secondaryApi: SecondaryPagesApi - private lateinit var hardcodedPages: HardcodedPages - private lateinit var pagesDatabase: PagesDatabase - - @BeforeTest - fun before() { - api = PrimaryPagesApi() - secondaryApi = SecondaryPagesApi() - hardcodedPages = HardcodedPages() - pagesDatabase = PagesDatabase() - } - - @Test - fun givenEmptyStoreWhenSuccessFromPrimaryApiThenStoreReadResponseOfPrimaryApiResult() = - testScope.runTest { - val ttl = null - val fail = false - - val hardcodedPagesFetcher = Fetcher.of { key -> hardcodedPages.get(key) } - val secondaryApiFetcher = - Fetcher.withFallback( - secondaryApi.name, - hardcodedPagesFetcher, - ) { key -> secondaryApi.get(key) } - - val store = - StoreBuilder.from( - fetcher = Fetcher.withFallback(api.name, secondaryApiFetcher) { key -> api.fetch(key, fail, ttl) }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> pagesDatabase.get(key) }, - writer = { key, page -> pagesDatabase.put(key, page) }, - delete = null, - deleteAll = null, - ), - ).build() - - val responses = store.stream(StoreReadRequest.fresh("1")).take(2).toList() - - assertEquals( - listOf( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data(Page.Data("1", null), StoreReadResponseOrigin.Fetcher(api.name)), - ), - responses, - ) - } - - @Test - fun givenEmptyStoreWhenFailureFromPrimaryApiThenStoreReadResponseOfSecondaryApiResult() = - testScope.runTest { - val ttl = null - val fail = true - - val hardcodedPagesFetcher = Fetcher.of { key -> hardcodedPages.get(key) } - val secondaryApiFetcher = - Fetcher.withFallback( - secondaryApi.name, - hardcodedPagesFetcher, - ) { key -> secondaryApi.get(key) } - - val store = - StoreBuilder.from( - fetcher = Fetcher.withFallback(api.name, secondaryApiFetcher) { key -> api.fetch(key, fail, ttl) }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> pagesDatabase.get(key) }, - writer = { key, page -> pagesDatabase.put(key, page) }, - delete = null, - deleteAll = null, - ), - ).build() - - val responses = store.stream(StoreReadRequest.fresh("1")).take(2).toList() - - assertEquals( - listOf( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data( - Page.Data("1", null), - StoreReadResponseOrigin.Fetcher(secondaryApiFetcher.name), - ), - ), - responses, - ) - } - - @Test - fun givenEmptyStoreWhenFailureFromPrimaryAndSecondaryApisThenStoreReadResponseOfHardcodedData() = - - testScope.runTest { - val ttl = null - val fail = true - - val hardcodedPagesFetcher = Fetcher.of { key -> hardcodedPages.get(key) } - val throwingSecondaryApiFetcher = - Fetcher.withFallback(secondaryApi.name, hardcodedPagesFetcher) { throw Exception() } - - val store = - StoreBuilder.from( - fetcher = - Fetcher.withFallback(api.name, throwingSecondaryApiFetcher) { key -> - api.fetch( - key, - fail, - ttl, - ) - }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> pagesDatabase.get(key) }, - writer = { key, page -> pagesDatabase.put(key, page) }, - delete = null, - deleteAll = null, - ), - ).build() - - val responses = store.stream(StoreReadRequest.fresh("1")).take(2).toList() - - assertEquals( - listOf( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - StoreReadResponse.Data( - Page.Data("1", null), - StoreReadResponseOrigin.Fetcher(hardcodedPagesFetcher.name), - ), - ), - responses, - ) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherControllerTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherControllerTests.kt deleted file mode 100644 index e8ef19509..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherControllerTests.kt +++ /dev/null @@ -1,141 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.StoreReadResponse.Data -import org.mobilenativefoundation.store.store5.impl.FetcherController -import kotlin.test.Test -import kotlin.test.assertEquals - -@ExperimentalCoroutinesApi -@FlowPreview -class FetcherControllerTests { - private val testScope = TestScope() - - @Test - fun simple() = - testScope.runTest { - val fetcherController = - FetcherController( - scope = testScope, - realFetcher = - Fetcher.ofResultFlow { key: Int -> - flow { - emit(FetcherResult.Data(key * key) as FetcherResult) - } - }, - sourceOfTruth = null, - ) - val fetcher = fetcherController.getFetcher(3) - assertEquals(0, fetcherController.fetcherSize()) - val received = - fetcher.onEach { - assertEquals(1, fetcherController.fetcherSize()) - }.first() - assertEquals( - Data( - value = 9, - origin = StoreReadResponseOrigin.Fetcher(), - ), - received, - ) - assertEquals(0, fetcherController.fetcherSize()) - } - - @Test - fun concurrent() = - testScope.runTest { - var createdCnt = 0 - val fetcherController = - FetcherController( - scope = testScope, - realFetcher = - Fetcher.ofResultFlow { key: Int -> - createdCnt++ - flow { - // make sure it takes time, otherwise, we may not share - delay(1) - emit(FetcherResult.Data(key * key) as FetcherResult) - } - }, - sourceOfTruth = null, - ) - val fetcherCount = 20 - - fun createFetcher() = - async { - fetcherController.getFetcher(3) - .onEach { - assertEquals(1, fetcherController.fetcherSize()) - }.first() - } - - val fetchers = - (0 until fetcherCount).map { - createFetcher() - } - fetchers.forEach { - assertEquals( - Data( - value = 9, - origin = StoreReadResponseOrigin.Fetcher(), - ), - it.await(), - ) - } - assertEquals(0, fetcherController.fetcherSize()) - assertEquals(1, createdCnt) - } - - @Test - fun concurrent_when_cancelled() = - testScope.runTest { - var createdCnt = 0 - val job = SupervisorJob() - val scope = TestScope(StandardTestDispatcher() + job) - val fetcherController = - FetcherController( - scope = scope, - realFetcher = - Fetcher.ofResultFlow { key: Int -> - createdCnt++ - flow { - // make sure it takes time, otherwise, we may not share - advanceUntilIdle() - emit(FetcherResult.Data(key * key) as FetcherResult) - } - }, - sourceOfTruth = null, - ) - val fetcherCount = 20 - - fun createFetcher() = - scope.launch { - fetcherController.getFetcher(3) - .onEach { - assertEquals(1, fetcherController.fetcherSize()) - }.first() - } - - (0 until fetcherCount).map { - createFetcher() - } - scope.advanceUntilIdle() - job.cancelChildren() - scope.advanceUntilIdle() - assertEquals(0, fetcherController.fetcherSize()) - assertEquals(1, createdCnt) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherResponseTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherResponseTests.kt deleted file mode 100644 index bc1ad5037..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FetcherResponseTests.kt +++ /dev/null @@ -1,300 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith - -@ExperimentalCoroutinesApi -@FlowPreview -class FetcherResponseTests { - private val testScope = TestScope() - - @Test - fun givenAFetcherThatThrowsAnExceptionInInvokeWhenStreamingThenTheExceptionsShouldNotBeCaught() = - testScope.runTest { - val store = - StoreBuilder.from( - Fetcher.ofResult { - throw RuntimeException("don't catch me") - }, - ).buildWithTestScope() - - assertFailsWith(message = "don't catch me") { - val result = store.stream(StoreReadRequest.fresh(1)).toList() - assertEquals(0, result.size) - } - } - - @Test - fun givenAFetcherThatEmitsErrorAndDataWhenSteamingThenItCanEmitValueAfterAnError() = - testScope.runTest { - val exception = RuntimeException("first error") - val store = - StoreBuilder.from( - fetcher = - Fetcher.ofResultFlow { key: Int -> - flowOf( - FetcherResult.Error.Exception(exception), - FetcherResult.Data("$key"), - ) - }, - ).buildWithTestScope() - - store.stream(StoreReadRequest.fresh(1)).test { - assertEquals( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception(exception, StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data("1", StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - } - } - - @Test - fun givenTransformerWhenRawValueThenUnwrappedValueReturnedAndValueIsCached() = - testScope.runTest { - val fetcher = Fetcher.ofFlow { flowOf(it * it) } - val pipeline = - StoreBuilder - .from(fetcher).buildWithTestScope() - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = 9, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Data( - value = 9, - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - } - } - - @Test - fun givenTransformerWhenErrorMessageThenErrorReturnedToUserAndErrorIsNotCached() = - testScope.runTest { - var count = 0 - val fetcher = - Fetcher.ofResultFlow { _: Int -> - flowOf(count++).map { - if (it > 0) { - FetcherResult.Data(it) - } else { - FetcherResult.Error.Message("zero") - } - } - } - val pipeline = - StoreBuilder.from(fetcher) - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Message( - message = "zero", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = 1, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun givenTransformerWhenErrorExceptionThenErrorReturnedToUserAndErrorIsNotCached() = - testScope.runTest { - val e = Exception() - var count = 0 - val fetcher = - Fetcher.ofResultFlow { _: Int -> - flowOf(count++).map { - if (it > 0) { - FetcherResult.Data(it) - } else { - FetcherResult.Error.Exception(e) - } - } - } - val pipeline = - StoreBuilder - .from(fetcher) - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = e, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = 1, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun givenExceptionsAsErrorsWhenExceptionThrownThenErrorReturnedToUserAndErrorIsNotCached() = - testScope.runTest { - var count = 0 - val e = Exception() - val fetcher = - Fetcher.of { - count++ - if (count == 1) { - throw e - } - count - 1 - } - val pipeline = - StoreBuilder - .from(fetcher = fetcher) - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = e, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = 1, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun givenAFetcherThatEmitsCustomErrorWhenStreamingThenCustomErrorShouldBeEmitted() = - testScope.runTest { - data class TestCustomError(val errorMessage: String) - - val customError = TestCustomError("Test custom error") - - val store = - StoreBuilder.from( - fetcher = - Fetcher.ofResultFlow { _: Int -> - flowOf( - FetcherResult.Error.Custom(customError), - ) - }, - ).buildWithTestScope() - - store.stream(StoreReadRequest.fresh(1)).test { - assertEquals( - StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Custom( - error = customError, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - private fun StoreBuilder.buildWithTestScope() = scope(testScope).build() -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FlowStoreTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FlowStoreTests.kt deleted file mode 100644 index aacbbda83..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/FlowStoreTests.kt +++ /dev/null @@ -1,1341 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.async -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.StoreReadResponse.Data -import org.mobilenativefoundation.store.store5.StoreReadResponse.Loading -import org.mobilenativefoundation.store.store5.impl.extensions.fresh -import org.mobilenativefoundation.store.store5.util.FakeFetcher -import org.mobilenativefoundation.store.store5.util.FakeFlowingFetcher -import org.mobilenativefoundation.store.store5.util.InMemoryPersister -import org.mobilenativefoundation.store.store5.util.asFlowable -import org.mobilenativefoundation.store.store5.util.asSourceOfTruth -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals -import kotlin.test.assertIs - -@FlowPreview -@ExperimentalCoroutinesApi -class FlowStoreTests { - private val testScope = TestScope() - - @Test - fun getAndFresh() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder - .from(fetcher) - .buildWithTestScope() - - assertEquals( - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).take(2).toList(), - listOf( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ), - ) - - assertEquals( - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).take(1).toList(), - listOf( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ), - ) - - assertEquals( - pipeline.stream(StoreReadRequest.fresh(3)).take(2).toList(), - listOf( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ), - ) - - assertEquals( - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).take(1).toList(), - listOf( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Cache, - ), - ), - ) - } - - @Test - fun getAndFresh_withPersister() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val persister = InMemoryPersister() - val pipeline = - StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ).buildWithTestScope() - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - // note that we still get the data from persister as well as we don't listen to - // the persister for the cached items unless there is an active stream, which - // means cache can go out of sync w/ the persister - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - } - - @Test - fun streamAndFresh_withPersister() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val persister = InMemoryPersister() - - val pipeline = - StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ).buildWithTestScope() - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun streamAndFresh() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder.from(fetcher = fetcher) - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun skipCache() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder.from(fetcher = fetcher) - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.skipMemory(3, refresh = false)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.skipMemory(3, refresh = false)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun flowingFetcher() = - testScope.runTest { - val fetcher = - FakeFlowingFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val persister = InMemoryPersister() - - val pipeline = - StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .buildWithTestScope() - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun diskChangeWhileNetworkIsFlowing_simple() = - testScope.runTest { - val persister = InMemoryPersister().asFlowable() - val pipeline = - StoreBuilder.from( - Fetcher.ofFlow { - flow { - delay(20) - emit("three-1") - } - }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .buildWithTestScope() - - launch { - delay(10) - persister.flowWriter(3, "local-1") - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun diskChangeWhileNetworkIsFlowing_overwrite() = - testScope.runTest { - val persister = InMemoryPersister().asFlowable() - val pipeline = - StoreBuilder.from( - fetcher = - Fetcher.ofFlow { - flow { - delay(10) - emit("three-1") - delay(10) - emit("three-2") - } - }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .buildWithTestScope() - - launch { - delay(5) - persister.flowWriter(3, "local-1") - delay(10) // go in between two server requests - persister.flowWriter(3, "local-2") - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-2", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun errorTest() = - testScope.runTest { - val exception = IllegalArgumentException("wow") - val persister = InMemoryPersister().asFlowable() - val pipeline = - StoreBuilder.from( - Fetcher.of { - throw exception - }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .buildWithTestScope() - - launch { - delay(10) - persister.flowWriter(3, "local-1") - } - - pipeline.stream(StoreReadRequest.cached(key = 3, refresh = true)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = exception, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(key = 3, refresh = true)).test { - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = exception, - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun givenSourceOfTruthWhenStreamFreshDataReturnsNoDataFromFetcherThenFetchReturnsNoDataAndCachedValuesAreReceived() = - testScope.runTest { - val persister = InMemoryPersister().asFlowable() - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.ofFlow { flow {} }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .buildWithTestScope() - - persister.flowWriter(3, "local-1") - val firstFetch = pipeline.fresh(3) // prime the cache - assertEquals("local-1", firstFetch) - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.NoNewData( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - } - - @Test - fun givenSourceOfTruthWhenStreamCachedDataWithRefreshReturnsNoNewDataThenCachedValuesAreReceivedAndFetchReturnsNoData() = - testScope.runTest { - val persister = InMemoryPersister().asFlowable() - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.ofFlow { flow {} }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .buildWithTestScope() - - persister.flowWriter(3, "local-1") - val firstFetch = pipeline.fresh(3) // prime the cache - assertEquals("local-1", firstFetch) - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "local-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.NoNewData( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun givenNoSourceOfTruthWhenStreamFreshDataReturnsNoDataFromFetcherThenFetchReturnsNoDataAndCachedValuesAreReceived() = - testScope.runTest { - var createCount = 0 - val pipeline = - StoreBuilder.from( - fetcher = - Fetcher.ofFlow { - if (createCount++ == 0) { - flowOf("remote-1") - } else { - flowOf() - } - }, - ) - .buildWithTestScope() - - val firstFetch = pipeline.fresh(3) // prime the cache - assertEquals("remote-1", firstFetch) - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.NoNewData( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - Data( - value = "remote-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - } - } - - @Test - fun givenNoSoTWhenStreamCachedDataWithRefreshReturnsNoNewDataThenCachedValuesAreReceivedAndFetchReturnsNoData() = - testScope.runTest { - var createCount = 0 - val pipeline = - StoreBuilder.from( - fetcher = - Fetcher.ofFlow { - if (createCount++ == 0) { - flowOf("remote-1") - } else { - flowOf() - } - }, - ) - .buildWithTestScope() - - val firstFetch = pipeline.fresh(3) // prime the cache - assertEquals("remote-1", firstFetch) - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data( - value = "remote-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.NoNewData( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun givenNoSourceOfTruthAndCacheHitWhenStreamCachedDataWithoutRefreshThenNoFetchIsTriggeredAndReceivesFollowingNetworkUpdates() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val store = - StoreBuilder.from(fetcher = fetcher) - .buildWithTestScope() - - val firstFetch = store.fresh(3) - assertEquals("three-1", firstFetch) - val secondCollect = mutableListOf>() - val collection = - launch { - store.stream(StoreReadRequest.cached(3, refresh = false)).collect { - secondCollect.add(it) - } - } - testScope.runCurrent() - assertEquals(1, secondCollect.size) - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ) - // trigger another fetch from network - val secondFetch = store.fresh(3) - assertEquals("three-2", secondFetch) - testScope.runCurrent() - // make sure cached also received it - assertEquals(2, secondCollect.size) - - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ) - assertContains( - secondCollect, - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ) - - collection.cancelAndJoin() - } - - @Test - fun givenSourceOfTruthAndCacheHitWhenStreamCachedDataWithoutRefreshThenNoFetchIsTriggeredAndReceivesFollowingNetworkUpdates() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val persister = InMemoryPersister() - val pipeline = - StoreBuilder.from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ).buildWithTestScope() - - val firstFetch = pipeline.fresh(3) - assertEquals("three-1", firstFetch) - val secondCollect = mutableListOf>() - val collection = - launch { - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).collect { - secondCollect.add(it) - } - } - testScope.runCurrent() - assertEquals(2, secondCollect.size) - - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ) - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - ) - - // trigger another fetch from network - val secondFetch = pipeline.fresh(3) - assertEquals("three-2", secondFetch) - testScope.runCurrent() - // make sure cached also received it - assertEquals(3, secondCollect.size) - - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - ) - assertContains( - secondCollect, - Data( - value = "three-1", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - ) - - assertContains( - secondCollect, - Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ) - collection.cancelAndJoin() - } - - @Test - fun testSlowFirstCollectorGetsAllFetchUpdatesOthersGetCacheAndLatestFetchResult() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - 3 to "three-3", - ) - val pipeline = - StoreBuilder.from( - fetcher = fetcher, - ).buildWithTestScope() - - val fetcher1Collected = mutableListOf>() - val fetcher1Job = - async { - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).collect { - fetcher1Collected.add(it) - delay(1_000) - } - } - testScope.advanceUntilIdle() - assertEquals( - listOf( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), - ), - fetcher1Collected, - ) - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data(origin = StoreReadResponseOrigin.Cache, value = "three-1"), - awaitItem(), - ) - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data(origin = StoreReadResponseOrigin.Cache, value = "three-2"), - awaitItem(), - ) - - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - assertEquals( - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-3"), - awaitItem(), - ) - } - - testScope.advanceUntilIdle() - assertEquals( - listOf( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-3"), - ), - fetcher1Collected, - ) - - fetcher1Job.cancelAndJoin() - } - - @Test - fun testFirstStreamGetsTwoFetchUpdatesSecondGetsCacheAndFetchResult() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder.from(fetcher = fetcher) - .buildWithTestScope() - - val fetcher1Collected = mutableListOf>() - val fetcher1Job = - async { - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).collect { - fetcher1Collected.add(it) - } - } - testScope.runCurrent() - assertEquals( - listOf( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), - ), - fetcher1Collected, - ) - - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - Data(origin = StoreReadResponseOrigin.Cache, value = "three-1"), - awaitItem(), - ) - - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), - awaitItem(), - ) - } - - testScope.runCurrent() - assertEquals( - listOf( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-1"), - Data(origin = StoreReadResponseOrigin.Fetcher(), value = "three-2"), - ), - fetcher1Collected, - ) - - fetcher1Job.cancelAndJoin() - } - - suspend fun Store.get(request: StoreReadRequest) = this.stream(request).filter { it.dataOrNull() != null }.first() - - suspend fun Store.get(key: Int) = - get( - StoreReadRequest.cached( - key = key, - refresh = false, - ), - ) - - private fun StoreBuilder.buildWithTestScope() = scope(testScope).build() - - @Test - fun stream_givenConverterThrows_thenEmitsError() = - testScope.runTest { - // Given - val exception = IllegalStateException("Converter failed") - val persister = InMemoryPersister() - - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.of { _: Int -> "network" }, - sourceOfTruth = persister.asSourceOfTruth(), - converter = - object : Converter { - override fun fromNetworkToLocal(network: String): String { - throw exception - } - - override fun fromOutputToLocal(output: String): String = output - }, - ).buildWithTestScope() - - // When + Then - pipeline.stream(StoreReadRequest.fresh(1)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - val errorResponse = awaitItem() - assertIs(errorResponse) - assertEquals(exception.message, errorResponse.error.message) - } - } - - @Test - fun stream_givenNamedFetcherAndConverterThrows_thenErrorContainsFetcherName() = - testScope.runTest { - // Given - val fetcherName = "TestFetcher" - val exception = IllegalStateException("Converter failed") - val persister = InMemoryPersister() - - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.of(name = fetcherName) { _: Int -> "network" }, - sourceOfTruth = persister.asSourceOfTruth(), - converter = - object : Converter { - override fun fromNetworkToLocal(network: String): String { - throw exception - } - - override fun fromOutputToLocal(output: String): String = output - }, - ).buildWithTestScope() - - // When + Then - pipeline.stream(StoreReadRequest.fresh(1)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - val errorResponse = awaitItem() - assertIs(errorResponse) - val origin = errorResponse.origin - assertIs(origin) - assertEquals(fetcherName, origin.name) - } - } - - @Test - fun stream_givenConverterThrowsWithFreshRequest_thenFlowCompletes() = - testScope.runTest { - // Given: fresh() request skips disk cache and fallBackToSourceOfTruth defaults to false - val exception = IllegalStateException("Converter failed") - val persister = InMemoryPersister() - - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.of { _: Int -> "network" }, - sourceOfTruth = persister.asSourceOfTruth(), - converter = - object : Converter { - override fun fromNetworkToLocal(network: String): String { - throw exception - } - - override fun fromOutputToLocal(output: String): String = output - }, - ).buildWithTestScope() - - // When + Then: Flow should complete, not hang indefinitely - pipeline.stream(StoreReadRequest.fresh(1)).test { - assertEquals( - Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - val errorResponse = awaitItem() - assertIs(errorResponse) - assertEquals(exception.message, errorResponse.error.message) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun stream_givenConverterThrowsWithFallbackDisabled_thenDiskDataNotEmitted() = - testScope.runTest { - // Given: Pre-populate disk with data, then request fresh with fallBackToSourceOfTruth=false - val exception = IllegalStateException("Converter failed") - val persister = InMemoryPersister() - persister.write(1, "cached value") - - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.of { _: Int -> "network" }, - sourceOfTruth = persister.asSourceOfTruth(), - converter = - object : Converter { - override fun fromNetworkToLocal(network: String): String { - throw exception - } - - override fun fromOutputToLocal(output: String): String = output - }, - ).buildWithTestScope() - - // When: Request with fallBackToSourceOfTruth=false - pipeline.stream(StoreReadRequest.fresh(1, fallBackToSourceOfTruth = false)).test { - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - // Then: Only error is emitted, no disk data (since fallback is disabled) - val errorResponse = awaitItem() - assertIs(errorResponse) - assertEquals(exception.message, errorResponse.error.message) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun stream_givenConverterThrowsWithFallbackEnabled_thenDiskDataEmitted() = - testScope.runTest { - // Given: Pre-populate disk with data, then request with fallBackToSourceOfTruth=true - val exception = IllegalStateException("Converter failed") - val persister = InMemoryPersister() - persister.write(1, "cached value") - - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.of { _: Int -> "network" }, - sourceOfTruth = persister.asSourceOfTruth(), - converter = - object : Converter { - override fun fromNetworkToLocal(network: String): String { - throw exception - } - - override fun fromOutputToLocal(output: String): String = output - }, - ).buildWithTestScope() - - // When: Request with fallBackToSourceOfTruth=true - pipeline.stream(StoreReadRequest.fresh(1, fallBackToSourceOfTruth = true)).test { - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - // Then: Error is emitted - val errorResponse = awaitItem() - assertIs(errorResponse) - - // And: Disk data is also emitted (since fallback is enabled) - val diskData = awaitItem() - assertIs>(diskData) - assertEquals("cached value", diskData.value) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun stream_givenConverterFailsThenSucceeds_thenSecondRequestEmitsData() = - testScope.runTest { - // Given: Converter that fails on first attempt and succeeds on second - var attempts = 0 - val exception = IllegalStateException("Converter failed") - val persister = InMemoryPersister() - - val pipeline = - StoreBuilder.from( - fetcher = Fetcher.of { _: Int -> "network value" }, - sourceOfTruth = persister.asSourceOfTruth(), - converter = - object : Converter { - override fun fromNetworkToLocal(network: String): String { - attempts++ - if (attempts == 1) { - throw exception - } - return network - } - - override fun fromOutputToLocal(output: String): String = output - }, - ).buildWithTestScope() - - // First request: fresh with fallback disabled (should error) - pipeline.stream(StoreReadRequest.fresh(1, fallBackToSourceOfTruth = false)).test { - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - val errorResponse = awaitItem() - assertIs(errorResponse) - assertEquals(exception.message, errorResponse.error.message) - cancelAndIgnoreRemainingEvents() - } - - // Second request: fresh again (should succeed and emit data) - pipeline.stream(StoreReadRequest.fresh(1, fallBackToSourceOfTruth = false)).test { - assertEquals( - Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - // Should receive data (Fetcher origin from SOT), not be skipped - val dataResponse = awaitItem() - assertIs>(dataResponse) - assertEquals("network value", dataResponse.value) - cancelAndIgnoreRemainingEvents() - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/HotFlowStoreTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/HotFlowStoreTests.kt deleted file mode 100644 index 7d3c2e64f..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/HotFlowStoreTests.kt +++ /dev/null @@ -1,102 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@ExperimentalCoroutinesApi -@FlowPreview -class HotFlowStoreTests { - private val testScope = TestScope() - - @Test - fun givenAHotFetcherWhenTwoCachedAndOneFreshCallThenFetcherIsOnlyCalledTwice() = - testScope.runTest { - val fetcher = - FakeFlowFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder - .from(fetcher) - .scope(testScope) - .build() - - val job = - launch { - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - pipeline.stream( - StoreReadRequest.cached(3, refresh = false), - ).test { - assertEquals( - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Cache, - ), - awaitItem(), - ) - } - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - job.cancel() - } -} - -private class FakeFlowFetcher( - vararg val responses: Pair, -) : Fetcher { - private var index = 0 - override val name: String? = null - - override val fallback: Fetcher? = null - - override fun invoke(key: Key): Flow> { - if (index >= responses.size) { - throw AssertionError("unexpected fetch request") - } - val pair = responses[index++] - assertEquals(key, pair.first) - return flowOf(FetcherResult.Data(pair.second)) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/KeyTrackerTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/KeyTrackerTests.kt deleted file mode 100644 index a38fb4d10..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/KeyTrackerTests.kt +++ /dev/null @@ -1,136 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.collectIndexed -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.util.KeyTracker -import kotlin.test.Test -import kotlin.test.assertEquals - -@ExperimentalCoroutinesApi -class KeyTrackerTests { - private val scope1 = TestScope() - private val scope2 = TestScope() - - private val subject = KeyTracker() - - @Test - fun dontSkipInvalidations() = - scope1.runTest { - val collection = - scope2.async { - subject.keyFlow('b') - .take(2) - .toList() - } - scope2.advanceUntilIdle() - assertEquals(1, subject.activeKeyCount()) - scope2.advanceUntilIdle() - subject.invalidate('a') - subject.invalidate('b') - subject.invalidate('c') - scope2.advanceUntilIdle() - assertEquals(true, collection.isCompleted) - assertEquals(0, subject.activeKeyCount()) - } - - @Test - fun multipleScopes() = - scope1.runTest { - val keys = 'a'..'z' - val collections = - keys.associate { key -> - key to - scope2.async { - subject.keyFlow(key) - .take(2) - .toList() - } - } - scope2.advanceUntilIdle() - assertEquals(26, subject.activeKeyCount()) - - scope2.advanceUntilIdle() - keys.forEach { - subject.invalidate(it) - } - scope2.advanceUntilIdle() - - collections.forEach { (_, deferred) -> - assertEquals(true, deferred.isCompleted) - } - assertEquals(0, subject.activeKeyCount()) - } - - @Test - fun multipleObservers() = - scope1.runTest { - val collections = - (0..4).map { - scope2.async { - subject.keyFlow('b') - .take(2) - .toList() - } - } - scope2.advanceUntilIdle() - assertEquals(1, subject.activeKeyCount()) - scope2.advanceUntilIdle() - subject.invalidate('a') - subject.invalidate('b') - subject.invalidate('c') - scope2.advanceUntilIdle() - collections.forEach { collection -> - assertEquals(true, collection.isCompleted) - } - assertEquals(0, subject.activeKeyCount()) - } - - @Test - fun keyFlow_notCollected_shouldNotBeTracked() = - scope1.runTest { - val flow = subject.keyFlow('b') - assertEquals(0, subject.activeKeyCount()) - scope2.launch { - flow.collectIndexed { index, value -> - assertEquals(1, index) - assertEquals(Unit, value) - assertEquals(1, subject.activeKeyCount()) - cancel() - } - } - assertEquals(0, subject.activeKeyCount()) - } - - @Test - fun keyFlow_trackerShouldRefCount() = - scope1.runTest { - val flow = subject.keyFlow('a') - assertEquals(0, subject.activeKeyCount()) - scope2.launch { - flow.collectIndexed { index, value -> - assertEquals(1, index) - assertEquals(Unit, value) - assertEquals(1, subject.activeKeyCount()) - cancel() - } - } - scope2.launch { - flow.collectIndexed { index, value -> - assertEquals(1, index) - assertEquals(Unit, value) - assertEquals(1, subject.activeKeyCount()) - cancel() - } - } - - assertEquals(0, subject.activeKeyCount()) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/LocalOnlyTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/LocalOnlyTests.kt deleted file mode 100644 index 632478bda..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/LocalOnlyTests.kt +++ /dev/null @@ -1,168 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.atomicfu.atomic -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.impl.extensions.get -import org.mobilenativefoundation.store.store5.util.InMemoryPersister -import org.mobilenativefoundation.store.store5.util.asSourceOfTruth -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlin.time.Duration - -class LocalOnlyTests { - private val testScope = TestScope() - - @Test - fun givenEmptyMemoryCacheThenCacheOnlyRequestReturnsNoNewData() = - testScope.runTest { - val store = - StoreBuilder - .from(Fetcher.of { _: Int -> throw RuntimeException("Fetcher shouldn't be hit") }) - .cachePolicy( - MemoryPolicy - .builder() - .build(), - ) - .build() - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.Cache), response) - } - - @Test - fun givenPrimedMemoryCacheThenCacheOnlyRequestReturnsData() = - testScope.runTest { - val fetcherHitCounter = atomic(0) - val store = - StoreBuilder - .from( - Fetcher.of { _: Int -> - fetcherHitCounter += 1 - "result" - }, - ) - .cachePolicy( - MemoryPolicy - .builder() - .build(), - ) - .build() - val a = store.get(0) - assertEquals("result", a) - assertEquals(1, fetcherHitCounter.value) - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals("result", response.requireData()) - assertEquals(1, fetcherHitCounter.value) - } - - @Test - fun givenInvalidMemoryCacheThenCacheOnlyRequestReturnsNoNewData() = - testScope.runTest { - val fetcherHitCounter = atomic(0) - val store = - StoreBuilder - .from( - Fetcher.of { _: Int -> - fetcherHitCounter += 1 - "result" - }, - ) - .cachePolicy( - MemoryPolicy - .builder() - .setExpireAfterWrite(Duration.ZERO) - .build(), - ) - .build() - val a = store.get(0) - assertEquals("result", a) - assertEquals(1, fetcherHitCounter.value) - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.Cache), response) - assertEquals(1, fetcherHitCounter.value) - } - - @Test - fun givenEmptyDiskCacheThenCacheOnlyRequestReturnsNoNewData() = - testScope.runTest { - val persister = InMemoryPersister() - val store = - StoreBuilder - .from( - fetcher = Fetcher.of { _: Int -> throw RuntimeException("Fetcher shouldn't be hit") }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .build() - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.SourceOfTruth), response) - } - - @Test - fun givenPrimedDiskCacheThenCacheOnlyRequestReturnsData() = - testScope.runTest { - val fetcherHitCounter = atomic(0) - val persister = InMemoryPersister() - val store = - StoreBuilder - .from( - fetcher = - Fetcher.of { _: Int -> - fetcherHitCounter += 1 - "result" - }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .build() - val a = store.get(0) - assertEquals("result", a) - assertEquals(1, fetcherHitCounter.value) - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals("result", response.requireData()) - assertEquals(StoreReadResponseOrigin.SourceOfTruth, response.origin) - assertEquals(1, fetcherHitCounter.value) - } - - @Test - fun givenInvalidDiskCacheThenCacheOnlyRequestReturnsNoNewData() = - testScope.runTest { - val fetcherHitCounter = atomic(0) - val persister = InMemoryPersister() - persister.write(0, "result") - val store = - StoreBuilder - .from( - fetcher = - Fetcher.of { _: Int -> - fetcherHitCounter += 1 - "result" - }, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .validator(Validator.by { false }) - .build() - val a = store.get(0) - assertEquals("result", a) - assertEquals(1, fetcherHitCounter.value) - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertEquals(StoreReadResponse.NoNewData(StoreReadResponseOrigin.SourceOfTruth), response) - assertEquals(1, fetcherHitCounter.value) - } - - @Test - fun givenNoCacheThenCacheOnlyRequestReturnsNoNewData() = - testScope.runTest { - val store = - StoreBuilder - .from(Fetcher.of { _: Int -> throw RuntimeException("Fetcher shouldn't be hit") }) - .disableCache() - .build() - val response = store.stream(StoreReadRequest.localOnly(0)).first() - assertTrue(response is StoreReadResponse.NoNewData) - assertEquals(StoreReadResponseOrigin.Cache, response.origin) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MapIndexedTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MapIndexedTests.kt deleted file mode 100644 index da4527c70..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/MapIndexedTests.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.impl.operators.mapIndexed -import kotlin.test.Test -import kotlin.test.assertEquals - -class MapIndexedTests { - private val scope = TestScope() - - @Test - fun mapIndexed() = - scope.runTest { - flowOf(5, 6).mapIndexed { index, value -> index to value }.test { - assertEquals(0 to 5, awaitItem()) - assertEquals(1 to 6, awaitItem()) - awaitComplete() - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthErrorsTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthErrorsTests.kt deleted file mode 100644 index 79808aaf6..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthErrorsTests.kt +++ /dev/null @@ -1,453 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.SourceOfTruth.ReadException -import org.mobilenativefoundation.store.store5.SourceOfTruth.WriteException -import org.mobilenativefoundation.store.store5.util.FakeFetcher -import org.mobilenativefoundation.store.store5.util.InMemoryPersister -import org.mobilenativefoundation.store.store5.util.asSourceOfTruth -import kotlin.test.Test -import kotlin.test.assertEquals - -@OptIn(ExperimentalCoroutinesApi::class) -@FlowPreview -class SourceOfTruthErrorsTests { - private val testScope = TestScope() - - @Test - fun givenSourceOfTruthWhenWriteFailsThenExceptionShouldBeSendToTheCollector() = - testScope.runTest { - val persister = InMemoryPersister() - val fetcher = - FakeFetcher( - 3 to "a", - 3 to "b", - ) - val pipeline = - StoreBuilder - .from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .scope(testScope) - .build() - persister.preWriteCallback = { _, _ -> - throw TestException("i fail") - } - - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = - WriteException( - key = 3, - value = "a", - cause = TestException("i fail"), - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - } - - @Test - fun givenSourceOfTruthWhenReadFailsThenExceptionShouldBeSendToTheCollector() = - testScope.runTest { - val persister = InMemoryPersister() - val fetcher = - FakeFetcher( - 3 to "a", - 3 to "b", - ) - val pipeline = - StoreBuilder - .from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .scope(testScope) - .build() - - persister.postReadCallback = { _, value -> - throw TestException(value ?: "null") - } - - pipeline.stream(StoreReadRequest.cached(3, refresh = false)).test { - assertEquals( - StoreReadResponse.Error.Exception( - error = - ReadException( - key = 3, - cause = TestException("null"), - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - // after disk fails, we should still invoke fetcher - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - // and after fetcher writes the value, it will trigger another read which will also - // fail - assertEquals( - StoreReadResponse.Error.Exception( - error = - ReadException( - key = 3, - cause = TestException("a"), - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - } - } - - @Test - fun givenSourceOfTruthWhenFirstWriteFailsThenItShouldKeepReadingFromFetcher() = - testScope.runTest { - val persister = InMemoryPersister() - val fetcher = - Fetcher.ofFlow { _: Int -> - flowOf("a", "b", "c", "d") - } - val pipeline = - StoreBuilder - .from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .scope(testScope) - .build() - persister.preWriteCallback = { _, value -> - if (value in listOf("a", "c")) { - throw TestException(value) - } - value - } - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - assertEquals( - StoreReadResponse.Error.Exception( - error = - WriteException( - key = 3, - value = "a", - cause = TestException("a"), - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "b", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Error.Exception( - error = - WriteException( - key = 3, - value = "c", - cause = TestException("c"), - ), - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - // disk flow will restart after a failed write (because we stopped it before the - // write attempt starts, so we will get the disk value again). - assertEquals( - StoreReadResponse.Data( - value = "b", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "d", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - -// @Test -// fun givenSourceOfTruthWithFailingWriteWhenAPassiveReaderArrivesThenItShouldReceiveTheNewWriteError() = testScope.runTest { -// val persister = InMemoryPersister() -// val fetcher = Fetcher.ofFlow { _: Int -> -// flowOf("a", "b", "c", "d") -// } -// val pipeline = StoreBuilder -// .from( -// fetcher = fetcher, -// sourceOfTruth = persister.asSourceOfTruth() -// ) -// .disableCache() -// .scope(testScope) -// .build() -// persister.preWriteCallback = { _, value -> -// if (value in listOf("a", "c")) { -// delay(50) -// throw TestException(value) -// } else { -// delay(10) -// } -// value -// } -// // keep collection hot -// val collector = launch { -// pipeline.stream( -// StoreReadRequest.cached(3, refresh = true) -// ).toList() -// } -// -// // miss writes for a and b and let the write operation for c start such that -// // we'll catch that write error -// delay(70) -// assertEmitsExactly( -// pipeline.stream(StoreReadRequest.cached(3, refresh = true)), -// listOf( -// // we wanted the disk value but write failed so we don't get it -// StoreReadResponse.Error.Exception( -// error = WriteException( -// key = 3, -// value = "c", -// cause = TestException("c") -// ), -// origin = StoreReadResponseOrigin.SourceOfTruth -// ), -// // after the write error, we should get the value on disk -// StoreReadResponse.Data( -// value = "b", -// origin = StoreReadResponseOrigin.SourceOfTruth -// ), -// // now we'll unlock the fetcher after disk is read -// StoreReadResponse.Loading( -// origin = StoreReadResponseOrigin.Fetcher( -// ), -// StoreReadResponse.Data( -// value = "d", -// origin = StoreReadResponseOrigin.Fetcher( -// ) -// ) -// ) -// collector.cancelAndJoin() -// } - - @Test - fun givenSourceOfTruthWithFailingWriteWhenAPassiveReaderArrivesThenItShouldNotGetErrorsHappenedBefore() = - testScope.runTest { - val persister = InMemoryPersister() - val fetcher = - Fetcher.ofFlow { - flow { - emit("a") - emit("b") - emit("c") - // now delay, wait for the new subscriber - delay(100) - emit("d") - } - } - val pipeline = - StoreBuilder - .from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .scope(testScope) - .build() - persister.preWriteCallback = { _, value -> - if (value in listOf("a", "c")) { - throw TestException(value) - } - value - } - val collector = - launch { - pipeline.stream( - StoreReadRequest.cached(3, refresh = true), - ).toList() // keep collection hot - } - - // miss both failures but arrive before d is fetched - delay(70) - - pipeline.stream(StoreReadRequest.skipMemory(3, refresh = true)).test { - assertEquals( - StoreReadResponse.Data( - value = "b", - origin = StoreReadResponseOrigin.SourceOfTruth, - ), - awaitItem(), - ) - - // don't receive the write exception because technically it started before we - // started reading - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "d", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - collector.cancelAndJoin() - } - -// @Test -// fun givenSourceOfTruthWithFailingWriteWhenAFreshValueReaderArrivesThenItShouldNotGetDiskErrorsFromAPendingWrite() = testScope.runTest { -// val persister = InMemoryPersister() -// val fetcher = Fetcher.ofFlow { -// flowOf("a", "b", "c", "d") -// } -// val pipeline = StoreBuilder -// .from( -// fetcher = fetcher, -// sourceOfTruth = persister.asSourceOfTruth() -// ) -// .disableCache() -// .scope(testScope) -// .build() -// persister.preWriteCallback = { _, value -> -// if (value == "c") { -// // slow down read so that the new reader arrives -// delay(50) -// } -// if (value in listOf("a", "c")) { -// throw TestException(value) -// } -// value -// } -// val collector = launch { -// pipeline.stream( -// StoreReadRequest.cached(3, refresh = true) -// ).toList() // keep collection hot -// } -// // miss both failures but arrive before d is fetched -// delay(20) -// assertEmitsExactly( -// pipeline.stream(StoreReadRequest.fresh(3)), -// listOf( -// StoreReadResponse.Loading( -// origin = StoreReadResponseOrigin.Fetcher( -// ), -// StoreReadResponse.Data( -// value = "d", -// origin = StoreReadResponseOrigin.Fetcher( -// ) -// ) -// ) -// collector.cancelAndJoin() -// } - - @Test - fun givenSourceOfTruthWithReadFailureWhenCachedValueReaderArrivesThenFetcherShouldBeCalledToGetANewValue() { - testScope.runTest { - val persister = InMemoryPersister() - val fetcher = Fetcher.of { _: Int -> "a" } - val pipeline = - StoreBuilder - .from( - fetcher = fetcher, - sourceOfTruth = persister.asSourceOfTruth(), - ) - .disableCache() - .scope(testScope) - .build() - persister.postReadCallback = { _, value -> - if (value == null) { - throw TestException("first read") - } - value - } - pipeline.stream(StoreReadRequest.cached(3, refresh = true)).test { - assertEquals( - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = - ReadException( - key = 3, - cause = TestException("first read"), - ), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "a", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - } - - private class TestException(val msg: String) : Exception(msg) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is TestException) return false - return msg == other.msg - } - - override fun hashCode(): Int { - return msg.hashCode() - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthWithBarrierTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthWithBarrierTests.kt deleted file mode 100644 index 656f9b91c..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/SourceOfTruthWithBarrierTests.kt +++ /dev/null @@ -1,285 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.async -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.SourceOfTruth.ReadException -import org.mobilenativefoundation.store.store5.SourceOfTruth.WriteException -import org.mobilenativefoundation.store.store5.impl.PersistentSourceOfTruth -import org.mobilenativefoundation.store.store5.impl.SourceOfTruthWithBarrier -import org.mobilenativefoundation.store.store5.util.InMemoryPersister -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -@FlowPreview -@ExperimentalCoroutinesApi -class SourceOfTruthWithBarrierTests { - private val testScope = TestScope() - private val persister = InMemoryPersister() - private val delegate: SourceOfTruth = - PersistentSourceOfTruth( - realReader = { key -> - flow { - emit(persister.read(key)) - } - }, - realWriter = persister::write, - realDelete = persister::deleteByKey, - realDeleteAll = persister::deleteAll, - ) - private val source = - SourceOfTruthWithBarrier( - delegate = delegate, - ) - - @Test - fun simple() = - testScope.runTest { - val collection = mutableListOf>() - - launch { - source.reader(1, CompletableDeferred(Unit)).take(2).collect { - collection.add(it) - } - } - delay(500) - source.write(1, "a") - advanceUntilIdle() - assertEquals( - listOf>( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = null, - ), - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = "a", - ), - ), - collection, - ) - assertEquals(0, source.barrierCount()) - } - - @Test - fun givenASourceOfTruthWhenDeleteIsCalledThenItIsDelegatedToThePersister() = - testScope.runTest { - persister.write(1, "a") - source.delete(1) - assertNull(persister.read(1)) - } - - @Test - fun givenASourceOfTruthWhenDeleteAllIsCalledThenItIsDelegatedToThePersister() = - testScope.runTest { - persister.write(1, "a") - persister.write(2, "b") - source.deleteAll() - assertNull(persister.read(1)) - assertNull(persister.read(2)) - } - - @Test - fun preAndPostWrites() = - testScope.runTest { - val collection = mutableListOf>() - source.write(1, "a") - - launch { - source.reader(1, CompletableDeferred(Unit)).take(2).collect { - collection.add(it) - } - } - - delay(200) - - source.write(1, "b") - - advanceUntilIdle() - - assertEquals( - listOf>( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = "a", - ), - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = "b", - ), - ), - collection, - ) - - assertEquals(0, source.barrierCount()) - } - - @Test - fun givenSourceOfTruthWhenReadFailsThenErrorShouldPropagate() = - testScope.runTest { - val exception = RuntimeException("read fails") - persister.postReadCallback = { key, value -> - throw exception - } - - source.reader(1, CompletableDeferred(Unit)).test { - assertEquals( - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = - ReadException( - key = 1, - cause = exception, - ), - ), - awaitItem(), - ) - } - } - - @Test - fun givenSourceOfTruthWhenReadFailsButThenSucceedsThenErrorShouldPropagateButAlsoTheValue() = - testScope.runTest { - var hasThrown = false - val exception = RuntimeException("read fails") - persister.postReadCallback = { _, value -> - if (!hasThrown) { - hasThrown = true - throw exception - } - value - } - val reader = source.reader(1, CompletableDeferred(Unit)) - val collected = mutableListOf>() - val collection = - async { - reader.collect { - collected.add(it) - } - } - advanceUntilIdle() - assertEquals( - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = - ReadException( - key = 1, - cause = exception, - ), - ), - collected.first(), - ) - // make sure it is not cancelled for the read error - assertEquals(true, collection.isActive) - // now insert another, it should trigger another read and emitted to the reader - source.write(1, "a") - advanceUntilIdle() - assertEquals( - listOf>( - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = - ReadException( - key = 1, - cause = exception, - ), - ), - StoreReadResponse.Data( - // this is fetcher since we are using the write API - origin = StoreReadResponseOrigin.Fetcher(), - value = "a", - ), - ), - collected, - ) - collection.cancelAndJoin() - } - - @Test - fun givenSourceOfTruthWhenWriteFailsThenErrorShouldPropagate() { - val failValue = "will fail" - testScope.runTest { - val exception = RuntimeException("write fails") - persister.preWriteCallback = { key, value -> - if (value == failValue) { - throw exception - } - value - } - val reader = source.reader(1, CompletableDeferred(Unit)) - val collected = mutableListOf>() - val collection = - async { - reader.collect { - collected.add(it) - } - } - advanceUntilIdle() - source.write(1, failValue) - advanceUntilIdle() - // make sure collection does not cancel for a write error - assertEquals(true, collection.isActive) - val eventsUntilFailure = - listOf( - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = null, - ), - StoreReadResponse.Error.Exception( - origin = StoreReadResponseOrigin.SourceOfTruth, - error = - WriteException( - key = 1, - value = failValue, - cause = exception, - ), - ), - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.SourceOfTruth, - value = null, - ), - ) - assertEquals(eventsUntilFailure, collected) - advanceUntilIdle() - assertEquals(true, collection.isActive) - // send another write that will succeed - source.write(1, "succeed") - advanceUntilIdle() - assertEquals( - eventsUntilFailure + - StoreReadResponse.Data( - origin = StoreReadResponseOrigin.Fetcher(), - value = "succeed", - ), - collected, - ) - collection.cancelAndJoin() - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponseTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponseTests.kt deleted file mode 100644 index f1d2c59ff..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreReadResponseTests.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertNull - -class StoreReadResponseTests { - @Test - fun requireData() { - assertEquals("Foo", StoreReadResponse.Data("Foo", StoreReadResponseOrigin.Fetcher()).requireData()) - - // should throw - assertFailsWith { - StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()).requireData() - } - } - - @Test - fun throwIfErrorException() { - assertFailsWith { - StoreReadResponse.Error.Exception(Exception(), StoreReadResponseOrigin.Fetcher()).throwIfError() - } - } - - @Test - fun throwIfErrorMessage() { - assertFailsWith { - StoreReadResponse.Error.Message("test error", StoreReadResponseOrigin.Fetcher()).throwIfError() - } - } - - @Test() - fun errorMessageOrNull() { - assertFailsWith(message = Exception::class.toString()) { - StoreReadResponse.Error.Exception(Exception(), StoreReadResponseOrigin.Fetcher()).throwIfError() - } - - assertFailsWith(message = "test error message") { - StoreReadResponse.Error.Message("test error message", StoreReadResponseOrigin.Fetcher()).throwIfError() - } - - assertNull(StoreReadResponse.Loading(StoreReadResponseOrigin.Fetcher()).errorMessageOrNull()) - } - - @Test - fun swapType() { - assertFailsWith { - StoreReadResponse.Data("Foo", StoreReadResponseOrigin.Fetcher()).swapType() - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreWithInMemoryCacheTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreWithInMemoryCacheTests.kt deleted file mode 100644 index 53f442b74..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StoreWithInMemoryCacheTests.kt +++ /dev/null @@ -1,133 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.impl.extensions.get -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.time.Duration.Companion.hours - -@OptIn(ExperimentalStoreApi::class) -@FlowPreview -@ExperimentalCoroutinesApi -class StoreWithInMemoryCacheTests { - @Test - fun storeRequestsCanCompleteWhenInMemoryCacheWithAccessExpiryIsAtTheMaximumSize() = - runTest { - val store = - StoreBuilder - .from(Fetcher.of { _: Int -> "result" }) - .cachePolicy( - MemoryPolicy - .builder() - .setExpireAfterAccess(1.hours) - .setMaxSize(1) - .build(), - ) - .build() - - val a = store.get(0) - val b = store.get(0) - val c = store.get(1) - val d = store.get(2) - - assertEquals("result", a) - assertEquals("result", b) - assertEquals("result", c) - assertEquals("result", d) - } - - @Test - fun storeDeadlock() = - runTest { - repeat(100) { - val store: MutableStore = - StoreBuilder - .from( - fetcher = Fetcher.of { key: Int -> "fetcher_$key" }, - sourceOfTruth = - SourceOfTruth.of( - reader = { key: Int -> - flowOf("source_of_truth_$key") - }, - writer = { key: Int, local: String -> }, - ), - ) - .disableCache() - .toMutableStoreBuilder( - converter = - object : Converter { - override fun fromNetworkToLocal(network: String): String = network - - override fun fromOutputToLocal(output: String): String = output - }, - ) - .build( - updater = - object : Updater { - var callCount = -1 - - override suspend fun post( - key: Int, - value: String, - ): UpdaterResult { - callCount += 1 - return if (callCount % 2 == 0) { - throw IllegalArgumentException("$key value: $value") - } else { - UpdaterResult.Success.Untyped("") - } - } - - override val onCompletion: OnUpdaterCompletion? = null - }, - ) - - val jobs = mutableListOf() - jobs.add( - store.stream(StoreReadRequest.cached(1, refresh = true)) - .mapNotNull { it.dataOrNull() } - .launchIn(this), - ) - val job1 = - store.stream(StoreReadRequest.cached(0, refresh = true)) - .mapNotNull { it.dataOrNull() } - .launchIn(this) - jobs.add( - store.stream(StoreReadRequest.cached(2, refresh = true)) - .mapNotNull { it.dataOrNull() } - .launchIn(this), - ) - jobs.add( - store.stream(StoreReadRequest.cached(3, refresh = true)) - .mapNotNull { it.dataOrNull() } - .launchIn(this), - ) - job1.cancel() - assertEquals( - expected = "source_of_truth_0", - actual = - store.stream(StoreReadRequest.cached(0, refresh = true)) - .mapNotNull { it.dataOrNull() } - .first(), - ) - jobs.forEach { - it.cancel() - assertEquals( - expected = "source_of_truth_0", - actual = - store.stream(StoreReadRequest.cached(0, refresh = true)) - .mapNotNull { it.dataOrNull() } - .first(), - ) - } - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StreamWithoutSourceOfTruthTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StreamWithoutSourceOfTruthTests.kt deleted file mode 100644 index a9a81c1b3..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/StreamWithoutSourceOfTruthTests.kt +++ /dev/null @@ -1,129 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.async -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.store5.util.FakeFetcher -import kotlin.test.Test -import kotlin.test.assertEquals - -@FlowPreview -@ExperimentalCoroutinesApi -class StreamWithoutSourceOfTruthTests { - private val testScope = TestScope() - - @Test - fun streamWithoutPersisterAndCacheEnabled() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder.from(fetcher) - .scope(testScope) - .build() - val twoItemsNoRefresh = - async { - pipeline.stream( - StoreReadRequest.cached(3, refresh = false), - ).take(3).toList() - } - delay(1_000) // make sure the async block starts first - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - assertEquals( - listOf( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ), - twoItemsNoRefresh.await(), - ) - } - - @Test - fun streamWithoutPersisterAndCacheDisabled() = - testScope.runTest { - val fetcher = - FakeFetcher( - 3 to "three-1", - 3 to "three-2", - ) - val pipeline = - StoreBuilder.from(fetcher) - .scope(testScope) - .disableCache() - .build() - val twoItemsNoRefresh = - async { - pipeline.stream( - StoreReadRequest.cached(3, refresh = false), - ).take(3).toList() - } - delay(1_000) // make sure the async block starts first - pipeline.stream(StoreReadRequest.fresh(3)).test { - assertEquals( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - assertEquals( - listOf( - StoreReadResponse.Loading( - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-1", - origin = StoreReadResponseOrigin.Fetcher(), - ), - StoreReadResponse.Data( - value = "three-2", - origin = StoreReadResponseOrigin.Fetcher(), - ), - ), - twoItemsNoRefresh.await(), - ) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt deleted file mode 100644 index d7898fee9..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/UpdaterTests.kt +++ /dev/null @@ -1,292 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.last -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.impl.extensions.inHours -import org.mobilenativefoundation.store.store5.util.fake.Notes -import org.mobilenativefoundation.store.store5.util.fake.NotesApi -import org.mobilenativefoundation.store.store5.util.fake.NotesBookkeeping -import org.mobilenativefoundation.store.store5.util.fake.NotesConverterProvider -import org.mobilenativefoundation.store.store5.util.fake.NotesDatabase -import org.mobilenativefoundation.store.store5.util.fake.NotesKey -import org.mobilenativefoundation.store.store5.util.fake.NotesUpdaterProvider -import org.mobilenativefoundation.store.store5.util.fake.NotesValidator -import org.mobilenativefoundation.store.store5.util.model.InputNote -import org.mobilenativefoundation.store.store5.util.model.NetworkNote -import org.mobilenativefoundation.store.store5.util.model.NoteData -import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse -import org.mobilenativefoundation.store.store5.util.model.OutputNote -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNotNull - -@OptIn(ExperimentalCoroutinesApi::class, ExperimentalStoreApi::class) -class UpdaterTests { - private val testScope = TestScope() - private lateinit var api: NotesApi - private lateinit var bookkeeping: NotesBookkeeping - private lateinit var notes: NotesDatabase - - @BeforeTest - fun before() { - api = NotesApi() - bookkeeping = NotesBookkeeping() - notes = NotesDatabase() - } - - @Test - fun givenNonEmptyMarketWhenWriteThenStoredAndAPIUpdated() = - testScope.runTest { - val ttl = inHours(1) - - val converter = NotesConverterProvider().provide() - val validator = NotesValidator() - val updater = NotesUpdaterProvider(api).provide() - val bookkeeper = - Bookkeeper.by( - getLastFailedSync = bookkeeping::getLastFailedSync, - setLastFailedSync = bookkeeping::setLastFailedSync, - clear = bookkeeping::clear, - clearAll = bookkeeping::clear, - ) - - val store = - MutableStoreBuilder.from( - fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> notes.get(key) }, - writer = { key, sot: InputNote -> notes.put(key, sot) }, - delete = { key -> notes.clear(key) }, - deleteAll = { notes.clear() }, - ), - converter = converter, - ) - .validator(validator) - .build( - updater = updater, - bookkeeper = bookkeeper, - ) - - val readRequest = StoreReadRequest.fresh(NotesKey.Single(Notes.One.id)) - - val stream = store.stream(readRequest) - - // Read is success - stream.test { - assertEquals( - StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - OutputNote(NoteData.Single(Notes.One), ttl = ttl), - StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - val newNote = Notes.One.copy(title = "New Title-1") - val writeRequest = - StoreWriteRequest.of( - key = NotesKey.Single(Notes.One.id), - value = OutputNote(NoteData.Single(newNote), 0), - ) - - val storeWriteResponse = store.write(writeRequest) - - // Write is success - assertEquals( - StoreWriteResponse.Success.Typed( - NotesWriteResponse( - NotesKey.Single(Notes.One.id), - true, - ), - ), - storeWriteResponse, - ) - - val cachedReadRequest = - StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = false) - val cachedStream = store.stream(cachedReadRequest) - - // Cache + SOT are updated - val firstResponse: StoreReadResponse = cachedStream.first() -// assertEquals( -// StoreReadResponse.Data( -// OutputNote(NoteData.Single(newNote), ttl = 0), -// StoreReadResponseOrigin.Cache -// ), - firstResponse -// ) - - val secondResponse = cachedStream.take(2).last() - assertIs>(secondResponse) - val data: NoteData? = secondResponse.value.data - assertIs(data) - assertNotNull(data) - assertEquals(newNote, data.item) - assertEquals(StoreReadResponseOrigin.SourceOfTruth, secondResponse.origin) - assertNotNull(secondResponse.value.ttl) - - // API is updated - assertEquals( - StoreWriteResponse.Success.Typed( - NotesWriteResponse( - NotesKey.Single(Notes.One.id), - true, - ), - ), - storeWriteResponse, - ) - assertEquals( - NetworkNote(NoteData.Single(newNote), ttl = null), - api.db[NotesKey.Single(Notes.One.id)], - ) - } - - @Test - fun givenNonEmptyMarketWithValidatorWhenInvalidThenSuccessOriginatingFromFetcher() = - testScope.runTest { - val ttl = inHours(1) - - val converter = NotesConverterProvider().provide() - val validator = NotesValidator(expiration = inHours(12)) - val updater = NotesUpdaterProvider(api).provide() - val bookkeeper = - Bookkeeper.by( - getLastFailedSync = bookkeeping::getLastFailedSync, - setLastFailedSync = bookkeeping::setLastFailedSync, - clear = bookkeeping::clear, - clearAll = bookkeeping::clear, - ) - - val store = - MutableStoreBuilder.from( - fetcher = Fetcher.of { key -> api.get(key, ttl = ttl) }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> notes.get(key) }, - writer = { key, sot: InputNote -> notes.put(key, sot) }, - delete = { key -> notes.clear(key) }, - deleteAll = { notes.clear() }, - ), - converter = converter, - ) - .validator(validator) - .build( - updater = updater, - bookkeeper = bookkeeper, - ) - - val readRequest = StoreReadRequest.fresh(NotesKey.Single(Notes.One.id)) - - val stream = store.stream(readRequest) - - // Fetch is success and validator is not used - stream.test { - assertEquals( - StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher()), - awaitItem(), - ) - - assertEquals( - StoreReadResponse.Data( - OutputNote(NoteData.Single(Notes.One), ttl = ttl), - StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - - val cachedReadRequest = - StoreReadRequest.cached(NotesKey.Single(Notes.One.id), refresh = false) - val cachedStream = store.stream(cachedReadRequest) - - // Cache + SOT are updated - // But item is invalid - // So we do not emit value in cache or SOT - // Instead we get latest from network even though refresh = false - - cachedStream.test { - assertEquals( - StoreReadResponse.Loading(origin = StoreReadResponseOrigin.Fetcher(name = null)), - awaitItem(), - ) - assertEquals( - StoreReadResponse.Data( - OutputNote(NoteData.Single(Notes.One), ttl = ttl), - StoreReadResponseOrigin.Fetcher(), - ), - awaitItem(), - ) - } - } - - @Test - fun givenEmptyMarketWhenWriteThenSuccessResponsesAndApiUpdated() = - testScope.runTest { - val converter = NotesConverterProvider().provide() - val validator = NotesValidator() - val updater = NotesUpdaterProvider(api).provide() - val bookkeeper = - Bookkeeper.by( - getLastFailedSync = bookkeeping::getLastFailedSync, - setLastFailedSync = bookkeeping::setLastFailedSync, - clear = bookkeeping::clear, - clearAll = bookkeeping::clear, - ) - - val store = - MutableStoreBuilder.from( - fetcher = - Fetcher.ofFlow { key -> - val network = api.get(key) - flow { emit(network) } - }, - sourceOfTruth = - SourceOfTruth.of( - nonFlowReader = { key -> notes.get(key) }, - writer = { key, sot -> notes.put(key, sot) }, - delete = { key -> notes.clear(key) }, - deleteAll = { notes.clear() }, - ), - converter, - ) - .validator(validator) - .build( - updater = updater, - bookkeeper = bookkeeper, - ) - - val newNote = Notes.One.copy(title = "New Title-1") - val writeRequest = - StoreWriteRequest.of( - key = NotesKey.Single(Notes.One.id), - value = OutputNote(NoteData.Single(newNote), 0), - ) - val storeWriteResponse = store.write(writeRequest) - - assertEquals( - StoreWriteResponse.Success.Typed( - NotesWriteResponse( - NotesKey.Single(Notes.One.id), - true, - ), - ), - storeWriteResponse, - ) - assertEquals(NetworkNote(NoteData.Single(newNote)), api.db[NotesKey.Single(Notes.One.id)]) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ValueFetcherTests.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ValueFetcherTests.kt deleted file mode 100644 index 26af2fb1f..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/ValueFetcherTests.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.mobilenativefoundation.store.store5 - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@ExperimentalCoroutinesApi -@FlowPreview -class ValueFetcherTests { - private val testScope = TestScope() - - @Test - fun givenValueFetcherWhenInvokeThenResultIsWrapped() = - testScope.runTest { - val fetcher = Fetcher.ofFlow { flowOf(it * it) } - - fetcher(3).test { - assertEquals(FetcherResult.Data(value = 9), awaitItem()) - awaitComplete() - } - } - - @Test - fun givenValueFetcherWhenExceptionInFlowThenExceptionReturnedAsResult() = - testScope.runTest { - val e = Exception() - val fetcher = - Fetcher.ofFlow { - flow { - throw e - } - } - fetcher(3).test { - assertEquals(FetcherResult.Error.Exception(e), awaitItem()) - awaitComplete() - } - } - - @Test - fun givenNonFlowValueFetcherWhenInvokeThenResultIsWrapped() = - testScope.runTest { - val fetcher = Fetcher.of { it * it } - - fetcher(3).test { - assertEquals(FetcherResult.Data(value = 9), awaitItem()) - awaitComplete() - } - } - - @Test - fun givenNonFlowValueFetcherWhenExceptionInFlowThenExceptionReturnedAsResult() = - testScope.runTest { - val e = Exception() - val fetcher = - Fetcher.of { - throw e - } - fetcher(3).test { - assertEquals(FetcherResult.Error.Exception(e), awaitItem()) - awaitComplete() - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/MutableStoreConcurrencyTest.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/MutableStoreConcurrencyTest.kt deleted file mode 100644 index 2a2b643ca..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/MutableStoreConcurrencyTest.kt +++ /dev/null @@ -1,126 +0,0 @@ -@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalStoreApi::class) - -package org.mobilenativefoundation.store.store5.mutablestore - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.cache5.CacheBuilder -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.StoreWriteRequest -import org.mobilenativefoundation.store.store5.StoreWriteResponse -import org.mobilenativefoundation.store.store5.Updater -import org.mobilenativefoundation.store.store5.UpdaterResult -import org.mobilenativefoundation.store.store5.impl.RealMutableStore -import org.mobilenativefoundation.store.store5.impl.RealStore -import org.mobilenativefoundation.store.store5.mutablestore.util.TestConverter -import org.mobilenativefoundation.store.store5.mutablestore.util.TestFetcher -import org.mobilenativefoundation.store.store5.mutablestore.util.TestLogger -import org.mobilenativefoundation.store.store5.mutablestore.util.TestValidator -import org.mobilenativefoundation.store.store5.mutablestore.util.testStore -import kotlin.test.Test -import kotlin.test.assertTrue - -/** - * Regression test for a data race in [RealMutableStore]'s per-key write-request queue. - * - * The queue is a non-thread-safe `ArrayDeque`. Mutating access goes through - * `withWriteRequestQueueLock`, which historically guarded it with a shared/reader lock that lets - * multiple holders run concurrently. As a result two operations on the same key could run at once: - * `addWriteRequestToQueue` doing `add(...)` while `updateWriteRequestQueue` iterates the same deque - * (`for (writeRequest in this)`). A structural `add` during iteration corrupts the backing array. - * - * On Kotlin/Native this surfaces as `EXC_BAD_ACCESS` (a hard process crash). On the JVM the deque's - * fail-fast iterator throws `ConcurrentModificationException`, which `RealMutableStore` catches and - * converts into a [StoreWriteResponse.Error.Exception]. Either way, with correct mutual exclusion - * every write should succeed. - * - * The delegate is backed by a real thread-safe cache (cache5) with no source of truth, so the only - * unsynchronized shared mutable state exercised here is the write-request queue itself. - */ -@OptIn(ExperimentalCoroutinesApi::class, ExperimentalStoreApi::class) -class MutableStoreConcurrencyTest { - private fun newMutableStore(): RealMutableStore { - val delegate: RealStore = - testStore( - fetcher = TestFetcher(), - sourceOfTruth = null, - converter = TestConverter(), - validator = TestValidator(), - memoryCache = CacheBuilder().build(), - ) - return RealMutableStore( - delegate = delegate, - updater = Updater.by({ _, value -> UpdaterResult.Success.Typed(value) }), - bookkeeper = null, - logger = TestLogger(), - ) - } - - @Test - fun sequentialWritesToSameKey_allSucceed() = - runTest { - val mutableStore = newMutableStore() - val key = "key" - val responses = (1..500).map { i -> mutableStore.write(StoreWriteRequest.of(key = key, value = i)) } - val failures = responses.filterIsInstance() - assertTrue( - failures.isEmpty(), - "Baseline sequential writes should all succeed, but ${failures.size} failed" + - (failures.firstOrNull()?.let { ", first error = ${it.error}" } ?: ""), - ) - } - - @Test - fun concurrentWritesToSameKey_doNotCorruptWriteQueue() = - runTest { - val mutableStore = newMutableStore() - val key = "key" - val concurrentWriters = 64 - val rounds = 50 - - repeat(rounds) { round -> - val responses = - coroutineScope { - (1..concurrentWriters) - .map { i -> - async(Dispatchers.Default) { - mutableStore.write( - StoreWriteRequest.of(key = key, value = round * concurrentWriters + i), - ) - } - } - .awaitAll() - } - - // A corrupted ArrayDeque surfaces as a memory-safety symptom: ConcurrentModificationException, - // NullPointerException, or IndexOutOfBoundsException on the JVM (EXC_BAD_ACCESS aborts the - // process on Native, so reaching this assertion at all already proves no native crash). - // NOTE: concurrent writes to the SAME key can still legitimately fail with - // IllegalArgumentException("No writes found ...") — a separate, pre-existing logical race - // where one write drains another's queue entry. That is not memory corruption and is out of - // scope for this fix, so it is tolerated here. - val corruption = - responses - .filterIsInstance() - .filter { response -> - when (response.error) { - is ConcurrentModificationException, - is NullPointerException, - is IndexOutOfBoundsException, - -> true - else -> false - } - } - assertTrue( - corruption.isEmpty(), - "Write-queue memory corruption in round $round: ${corruption.size}/${responses.size} " + - "writes hit a corruption-class error" + - (corruption.firstOrNull()?.let { ", first = ${it.error}" } ?: ""), - ) - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/RealMutableStoreTest.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/RealMutableStoreTest.kt deleted file mode 100644 index 734193b74..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/RealMutableStoreTest.kt +++ /dev/null @@ -1,485 +0,0 @@ -@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalStoreApi::class) - -package org.mobilenativefoundation.store.store5.mutablestore - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.runTest -import org.mobilenativefoundation.store.core5.ExperimentalStoreApi -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.StoreWriteRequest -import org.mobilenativefoundation.store.store5.StoreWriteResponse -import org.mobilenativefoundation.store.store5.impl.RealMutableStore -import org.mobilenativefoundation.store.store5.impl.RealStore -import org.mobilenativefoundation.store.store5.mutablestore.util.TestCache -import org.mobilenativefoundation.store.store5.mutablestore.util.TestConverter -import org.mobilenativefoundation.store.store5.mutablestore.util.TestFetcher -import org.mobilenativefoundation.store.store5.mutablestore.util.TestInMemoryBookkeeper -import org.mobilenativefoundation.store.store5.mutablestore.util.TestLogger -import org.mobilenativefoundation.store.store5.mutablestore.util.TestSourceOfTruth -import org.mobilenativefoundation.store.store5.mutablestore.util.TestUpdater -import org.mobilenativefoundation.store.store5.mutablestore.util.TestValidator -import org.mobilenativefoundation.store.store5.mutablestore.util.testStore -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -private data class Note(val id: String, val content: String) - -private data class NetworkNote(val id: String, val content: String) - -private data class DatabaseNote(val id: String, val content: String) - -@OptIn(ExperimentalCoroutinesApi::class, ExperimentalStoreApi::class) -class RealMutableStoreTest { - private lateinit var testFetcher: TestFetcher - private lateinit var testConverter: TestConverter - private lateinit var testValidator: TestValidator - private lateinit var testSourceOfTruth: TestSourceOfTruth - private lateinit var testCache: TestCache - - private lateinit var testUpdater: TestUpdater - private lateinit var testBookkeeper: TestInMemoryBookkeeper - private lateinit var testLogger: TestLogger - - private lateinit var delegateStore: RealStore - private lateinit var mutableStore: RealMutableStore - - @BeforeTest - fun setUp() { - testFetcher = TestFetcher() - val defaultLocalValue = DatabaseNote("defaultLocalId", "defaultLocalContent") - testConverter = - TestConverter( - defaultNetworkToLocalConverter = { defaultLocalValue }, - defaultOutputToLocalConverter = { defaultLocalValue }, - ) - testValidator = TestValidator() - testSourceOfTruth = TestSourceOfTruth() - testCache = TestCache() - - testFetcher.whenever("key1") { - flowOf(FetcherResult.Data(NetworkNote("networkId", "networkContent"))) - } - - testUpdater = TestUpdater() - testBookkeeper = TestInMemoryBookkeeper() - testLogger = TestLogger() - - delegateStore = - testStore( - fetcher = testFetcher, - sourceOfTruth = testSourceOfTruth, - converter = testConverter, - validator = testValidator, - memoryCache = testCache, - ) - - mutableStore = - RealMutableStore( - delegate = delegateStore, - updater = testUpdater, - bookkeeper = testBookkeeper, - logger = testLogger, - ) - } - - @Test - fun stream_givenNoConflicts_whenReading_thenEmitsFromDelegate() = - runTest { - // Given - val request = StoreReadRequest.Companion.cached("key1", refresh = true) - - // When - val results = mutableStore.stream(request).take(2).toList() - - // Then - assertTrue(results.size >= 2) - assertIs(results[0]) - assertIs>(results[1]) - } - - @Test - fun stream_givenConflictsAndBookkeeper_whenReading_thenAttemptsEagerConflictResolution() = - runTest { - // Given - val request = StoreReadRequest.Companion.cached("key2", refresh = true) - delegateStore.write("key2", Note("localId", "localContent")) - testBookkeeper.setLastFailedSync("key2") - testUpdater.successValue = NetworkNote("resolvedId", "resolvedContent") - - // When - val results = mutableStore.stream(request).take(2).toList() - - // Then - assertTrue(results.isNotEmpty()) - val foundResolutionLog = - testLogger.debugLogs.any { it.contains("resolvedContent") } || - testLogger.debugLogs.any { it.contains("No conflicts.") } - assertTrue(foundResolutionLog, "Expected conflict resolution attempt in debug logs") - assertEquals(null, testBookkeeper.getLastFailedSync("key2")) - assertIs>(results.last()) - } - - @Test - fun stream_givenConflictResolutionFails_whenReading_thenLogsErrorButContinues() = - runTest { - // Given - val request = StoreReadRequest.Companion.cached("key3", refresh = true) - val errorMessage = "Conflict not resolved" - - delegateStore.write("key3", Note("localId3", "localContent3")) - testBookkeeper.setLastFailedSync("key3") - testUpdater.errorMessage = errorMessage - - // When - val results = mutableStore.stream(request).take(2).toList() - - // Then - assertTrue(results.size >= 2) - assertTrue( - testLogger.errorLogs.any { (msg, _) -> msg.contains(errorMessage) }, - "Expected error logs due to conflict resolution failing", - ) - assertNotNull(testBookkeeper.getLastFailedSync("key3")) - } - - @Test - fun stream_givenWriteFlowAndNoConflicts_whenCollecting_thenLocalAndNetworkAreUpdated() = - runTest { - // Given - val requestsFlow = MutableSharedFlow>(replay = 1) - - // When - val responsesDeferred = - async { - mutableStore.stream(requestsFlow).take(1).toList() - } - - requestsFlow.emit( - StoreWriteRequest.Companion.of( - key = "writeKey1", - value = Note("localNoteId1", "localNoteContent1"), - created = 1111L, - onCompletions = null, - ), - ) - - val responses = responsesDeferred.await() - assertTrue(responses.first() is StoreWriteResponse.Success) - - // Then - val read = delegateStore.latestOrNull("writeKey1") - assertEquals("localNoteContent1", read?.content) - assertEquals(null, testBookkeeper.getLastFailedSync("writeKey1")) - } - - @Test - fun stream_givenWriteFlowAndNetworkFailure_whenCollecting_thenLocalIsUpdatedButConflictRemains() = - runTest { - // Given - val requestsFlow = MutableSharedFlow>(replay = 1) - testUpdater.errorMessage = "Network failure" - - // When - val responsesDeferred = - async { - mutableStore.stream(requestsFlow).take(1).toList() - } - - requestsFlow.emit( - StoreWriteRequest.Companion.of( - key = "writeKey2", - value = Note("localNoteId2", "localNoteContent2"), - created = 1111L, - onCompletions = null, - ), - ) - - val responses = responsesDeferred.await() - - // Then - val firstResponse = responses.first() - assertTrue(firstResponse is StoreWriteResponse.Error.Message) - assertTrue(firstResponse.message.contains("Network failure")) - val read = delegateStore.latestOrNull("writeKey2") - assertEquals("localNoteContent2", read?.content) - assertNotNull(testBookkeeper.getLastFailedSync("writeKey2")) - } - - @Test - fun stream_givenMultipleWritesForSameKey_whenAllSucceed_thenOlderRequestsAreClearedFromQueue() = - runTest { - // Given - val requestsFlow = MutableSharedFlow>(replay = 2) - testUpdater.successValue = NetworkNote("someNetId", "someNetContent") - val responsesDeferred = - async { - mutableStore.stream(requestsFlow).take(2).toList() - } - - // When - requestsFlow.emit( - StoreWriteRequest.Companion.of( - key = "multiKey", - value = Note("first", "firstContent"), - created = 100, - onCompletions = null, - ), - ) - requestsFlow.emit( - StoreWriteRequest.Companion.of( - key = "multiKey", - value = Note("second", "secondContent"), - created = 200, - onCompletions = null, - ), - ) - - // Then - val responses = responsesDeferred.await() - assertTrue(responses[0] is StoreWriteResponse.Success) - assertTrue(responses[1] is StoreWriteResponse.Success) - val read = delegateStore.latestOrNull("multiKey") - assertEquals("secondContent", read?.content) - assertNull(testBookkeeper.getLastFailedSync("multiKey")) - } - - @Test - fun write_givenSingleRequestAndNoNetworkIssues_whenCalled_thenSucceeds() = - runTest { - // Given - val request = - StoreWriteRequest.Companion.of( - key = "singleWriteKey", - value = Note("id", "content"), - created = 9999L, - onCompletions = null, - ) - - // When - val response = mutableStore.write(request) - - // Then - assertIs(response) - assertEquals("content", delegateStore.latestOrNull("singleWriteKey")?.content) - } - - @Test - fun write_givenSingleRequestAndNetworkException_whenCalled_thenFailsButLocalUpdated() = - runTest { - // Given - testUpdater.exception = IllegalStateException("Network error!") - val request = - StoreWriteRequest.Companion.of( - key = "exceptionKey", - value = Note("exceptionId", "contentException"), - created = 2222L, - onCompletions = null, - ) - - // When - val response = mutableStore.write(request) - - // Then - assertIs(response) - assertEquals("contentException", delegateStore.latestOrNull("exceptionKey")?.content) - assertNotNull(testBookkeeper.getLastFailedSync("exceptionKey")) - } - - @Test - fun write_givenSourceOfTruthFailure_whenCalled_thenSurfacesWriteError() = - runTest { - // Given - val key = "key" - val errorMessage = "write error" - testSourceOfTruth.throwOnWrite(key) { - IllegalStateException(errorMessage) - } - val request = - StoreWriteRequest.of( - key = key, - value = Note(key, "content"), - created = 3333L, - onCompletions = null, - ) - - // When - val response = mutableStore.write(request) - - // Then - val errorResponse = assertIs(response) - val writeException = assertIs(errorResponse.error) - val cause = assertIs(writeException.cause) - assertEquals(errorMessage, cause.message) - } - - @Test - fun write_givenSourceOfTruthFailure_whenCalled_thenNetworkSyncNotAttempted() = - runTest { - // Given - val key = "key" - testUpdater.postCallCount = 0 - testSourceOfTruth.throwOnWrite(key) { IllegalStateException("SOT failure") } - - val request = - StoreWriteRequest.of( - key = key, - value = Note(key, "content"), - created = 4444L, - onCompletions = null, - ) - - // When - val response = mutableStore.write(request) - - // Then - assertIs(response) - assertEquals(0, testUpdater.postCallCount, "Network updater should not be called when SOT write fails") - } - - @Test - fun write_givenSourceOfTruthFailure_whenCalled_thenMemCacheNotUpdated() = - runTest { - // Given - val key = "key" - testSourceOfTruth.throwOnWrite(key) { IllegalStateException("SOT failure") } - - val request = - StoreWriteRequest.of( - key = key, - value = Note(key, "content"), - created = 6666L, - onCompletions = null, - ) - - // When - val response = mutableStore.write(request) - - // Then - assertIs(response) - assertNull(delegateStore.latestOrNull(key), "Value should not be in cache after SOT write failure") - } - - @Test - fun write_givenNoSourceOfTruth_whenCalled_thenSucceeds() = - runTest { - // Given - val storeWithoutSot = - testStore( - fetcher = testFetcher, - sourceOfTruth = null, - converter = testConverter, - validator = testValidator, - memoryCache = testCache, - ) - val mutableStoreWithoutSot = - RealMutableStore( - delegate = storeWithoutSot, - updater = testUpdater, - bookkeeper = testBookkeeper, - logger = testLogger, - ) - - val request = - StoreWriteRequest.of( - key = "noSotKey", - value = Note("id", "content"), - created = 5555L, - onCompletions = null, - ) - - // When - val response = mutableStoreWithoutSot.write(request) - - // Then - assertIs(response) - } - - @Test - fun clearAll_givenSomeKeys_whenCalled_thenDelegateIsCleared() = - runTest { - // Given - delegateStore.write("clearKey1", Note("id1", "content1")) - delegateStore.write("clearKey2", Note("id2", "content2")) - assertNotNull(delegateStore.latestOrNull("clearKey1")) - assertNotNull(delegateStore.latestOrNull("clearKey2")) - - // When - mutableStore.clear() - - // Then - assertNull(delegateStore.latestOrNull("clearKey1")) - assertNull(delegateStore.latestOrNull("clearKey2")) - } - - @Test - fun clear_givenKey_whenCalled_thenDelegateIsClearedForThatKey() = - runTest { - // Given - delegateStore.write("clearKey", Note("idCleared", "contentCleared")) - assertNotNull(delegateStore.latestOrNull("clearKey")) - - // When - mutableStore.clear("clearKey") - - // Then - assertNull(delegateStore.latestOrNull("clearKey")) - } - - @Test - fun stream_givenNoBookkeeper_whenConflictsMightExistIsCalled_thenNoEagerResolutionIsAttempted() = - runTest { - // Given - val storeNoBookkeeper = - RealMutableStore( - delegate = delegateStore, - updater = testUpdater, - bookkeeper = null, - logger = testLogger, - ) - delegateStore.write("keyNoBook", Note("idNoBook", "contentNoBook")) - val request = StoreReadRequest.Companion.cached("keyNoBook", refresh = false) - - // When - val results = storeNoBookkeeper.stream(request).take(2).toList() - - // Then - assertTrue(results.isNotEmpty()) - assertTrue( - testLogger.debugLogs.none { it.contains("ConflictsResolved") }, - "No conflict resolution logs expected because no Bookkeeper", - ) - } - - @Test - fun write_givenKeyNotInitialized_whenCalled_thenStoreIsSafelyInitialized() = - runTest { - // Given - val request = - StoreWriteRequest.Companion.of( - key = "newKey", - value = Note("someId", "someContent"), - created = 777L, - onCompletions = null, - ) - - // When - val response = mutableStore.write(request) - - // Then - assertIs(response) - assertEquals("someContent", delegateStore.latestOrNull("newKey")?.content) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestCache.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestCache.kt deleted file mode 100644 index 78d22c91f..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestCache.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import org.mobilenativefoundation.store.cache5.Cache - -@Suppress("UNCHECKED_CAST") -class TestCache : Cache { - private val map = HashMap() - var getIfPresentCalls = 0 - var getOrPutCalls = 0 - var getAllPresentCalls = 0 - var putCalls = 0 - var putAllCalls = 0 - var invalidateCalls = 0 - var invalidateAllKeysCalls = 0 - var invalidateAllCalls = 0 - var sizeCalls = 0 - - override fun getIfPresent(key: Key): Value? { - getIfPresentCalls++ - return map[key] - } - - override fun getOrPut( - key: Key, - valueProducer: () -> Value, - ): Value { - getOrPutCalls++ - return map.getOrPut(key, valueProducer) - } - - override fun getAllPresent(keys: List<*>): Map { - getAllPresentCalls++ - return keys.mapNotNull { it as? Key }.associateWithNotNull { key -> map[key] } - } - - override fun put( - key: Key, - value: Value, - ) { - putCalls++ - map[key] = value - } - - override fun putAll(map: Map) { - putAllCalls++ - map.forEach { (k, v) -> put(k, v) } - } - - override fun invalidate(key: Key) { - invalidateCalls++ - map.remove(key) - } - - override fun invalidateAll(keys: List) { - invalidateAllKeysCalls++ - keys.forEach { map.remove(it) } - } - - override fun invalidateAll() { - invalidateAllCalls++ - map.clear() - } - - override fun size(): Long { - sizeCalls++ - return map.size.toLong() - } - - private inline fun Iterable.associateWithNotNull(transform: (K) -> V?): Map { - val destination = mutableMapOf() - for (element in this) { - transform(element)?.let { destination[element] = it } - } - return destination - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestConverter.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestConverter.kt deleted file mode 100644 index 845dcd439..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestConverter.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import org.mobilenativefoundation.store.store5.Converter - -@Suppress("UNCHECKED_CAST") -class TestConverter( - private val defaultNetworkToLocalConverter: ((Network) -> Local)? = null, - private val defaultOutputToLocalConverter: ((Output) -> Local)? = null, -) : Converter { - private val networkToLocalMap: HashMap = HashMap() - private val outputToLocalMap: HashMap = HashMap() - - fun wheneverNetwork( - network: Network, - block: () -> Local, - ) { - networkToLocalMap[network] = block() - } - - fun wheneverOutput( - output: Output, - block: () -> Local, - ) { - outputToLocalMap[output] = block() - } - - override fun fromNetworkToLocal(network: Network): Local { - return networkToLocalMap[network] ?: defaultNetworkToLocalConverter?.invoke(network) ?: network as Local - } - - override fun fromOutputToLocal(output: Output): Local { - return outputToLocalMap[output] ?: defaultOutputToLocalConverter?.invoke(output) ?: output as Local - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestFetcher.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestFetcher.kt deleted file mode 100644 index a074424a4..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestFetcher.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import kotlinx.coroutines.flow.Flow -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult - -class TestFetcher( - override val name: String? = null, - override val fallback: Fetcher? = null, -) : Fetcher { - private val faked = HashMap>>() - - fun whenever( - key: Key, - block: () -> Flow>, - ) { - faked[key] = block() - } - - override operator fun invoke(key: Key): Flow> { - return requireNotNull(faked[key]) { - "No fetcher result provided for key=$key" - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestInMemoryBookkeeper.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestInMemoryBookkeeper.kt deleted file mode 100644 index 1c159c81e..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestInMemoryBookkeeper.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import org.mobilenativefoundation.store.store5.Bookkeeper - -class TestInMemoryBookkeeper : Bookkeeper { - private val failedSyncMap = mutableMapOf() - - override suspend fun getLastFailedSync(key: Key): Long? { - return failedSyncMap[key] - } - - override suspend fun setLastFailedSync( - key: Key, - timestamp: Long, - ): Boolean { - failedSyncMap[key] = timestamp - return true - } - - override suspend fun clear(key: Key): Boolean { - return failedSyncMap.remove(key) != null - } - - override suspend fun clearAll(): Boolean { - failedSyncMap.clear() - return true - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestLogger.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestLogger.kt deleted file mode 100644 index 332ea0023..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestLogger.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import org.mobilenativefoundation.store.store5.Logger - -class TestLogger : Logger { - val debugLogs = mutableListOf() - val errorLogs = mutableListOf>() - - override fun debug(message: String) { - debugLogs.add(message) - } - - override fun error( - message: String, - throwable: Throwable?, - ) { - errorLogs.add(message to throwable) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestSourceOfTruth.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestSourceOfTruth.kt deleted file mode 100644 index 3b569e8d0..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestSourceOfTruth.kt +++ /dev/null @@ -1,67 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow -import org.mobilenativefoundation.store.store5.SourceOfTruth - -@Suppress("UNCHECKED_CAST") -class TestSourceOfTruth : SourceOfTruth { - private val storage = HashMap() - private val flows = HashMap>() - private var readError: Throwable? = null - private var writeError: Throwable? = null - private var deleteError: Throwable? = null - private var deleteAllError: Throwable? = null - - fun throwOnRead( - key: Key, - block: () -> Throwable, - ) { - readError = block() - } - - fun throwOnWrite( - key: Key, - block: () -> Throwable, - ) { - writeError = block() - } - - fun throwOnDelete( - key: Key?, - block: () -> Throwable, - ) { - if (key != null) deleteError = block() else deleteAllError = block() - } - - override fun reader(key: Key): Flow = - flow { - readError?.let { throw SourceOfTruth.ReadException(key, it) } - val sharedFlow = flows.getOrPut(key) { MutableSharedFlow(replay = 1) } - emit(storage[key] as Output?) - emitAll(sharedFlow) - } - - override suspend fun write( - key: Key, - value: Local, - ) { - writeError?.let { throw SourceOfTruth.WriteException(key, value, it) } - storage[key] = value - flows[key]?.emit(value as Output?) - } - - override suspend fun delete(key: Key) { - deleteError?.let { throw it } - storage.remove(key) - flows.remove(key) - } - - override suspend fun deleteAll() { - deleteAllError?.let { throw it } - storage.clear() - flows.clear() - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestStore.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestStore.kt deleted file mode 100644 index 62504297c..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestStore.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import org.mobilenativefoundation.store.cache5.Cache -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.Validator -import org.mobilenativefoundation.store.store5.impl.RealStore - -internal fun testStore( - dispatcher: CoroutineDispatcher = Dispatchers.Default, - scope: CoroutineScope = CoroutineScope(dispatcher), - fetcher: Fetcher = TestFetcher(), - sourceOfTruth: SourceOfTruth? = TestSourceOfTruth(), - converter: Converter = TestConverter(), - validator: Validator = TestValidator(), - memoryCache: Cache = TestCache(), -): RealStore = - RealStore( - scope = scope, - fetcher = fetcher, - sourceOfTruth = sourceOfTruth, - converter = converter, - validator = validator, - memCache = memoryCache, - ) diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestUpdater.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestUpdater.kt deleted file mode 100644 index f83dc8936..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestUpdater.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import org.mobilenativefoundation.store.store5.OnUpdaterCompletion -import org.mobilenativefoundation.store.store5.Updater -import org.mobilenativefoundation.store.store5.UpdaterResult - -class TestUpdater : Updater { - var exception: Throwable? = null - var errorMessage: String? = null - var successValue: Response? = null - var postCallCount: Int = 0 - - override suspend fun post( - key: Key, - value: Output, - ): UpdaterResult { - postCallCount++ - exception?.let { return UpdaterResult.Error.Exception(it) } - errorMessage?.let { return UpdaterResult.Error.Message(it) } - successValue?.let { return UpdaterResult.Success.Typed(it) } - return UpdaterResult.Success.Untyped(value) - } - - override val onCompletion: OnUpdaterCompletion? = null -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestValidator.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestValidator.kt deleted file mode 100644 index c6e852429..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/mutablestore/util/TestValidator.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.mobilenativefoundation.store.store5.mutablestore.util - -import org.mobilenativefoundation.store.store5.Validator - -class TestValidator : Validator { - private val map: HashMap = HashMap() - - fun whenever( - item: Output, - block: () -> Boolean, - ) { - map[item] = block() - } - - override suspend fun isValid(item: Output): Boolean { - return map[item] != false - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/AsFlowable.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/AsFlowable.kt deleted file mode 100644 index 60d8bb9ca..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/AsFlowable.kt +++ /dev/null @@ -1,132 +0,0 @@ -package org.mobilenativefoundation.store.store5.util - -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import org.mobilenativefoundation.store.store5.SourceOfTruth - -/** - * Only used in FlowStoreTest. We should get rid of it eventually. - */ -class SimplePersisterAsFlowable( - private val reader: suspend (Key) -> Output?, - private val writer: suspend (Key, Output) -> Unit, - private val delete: (suspend (Key) -> Unit)? = null, -) { - val supportsDelete: Boolean - get() = delete != null - - private val versionTracker = KeyTracker() - - fun flowReader(key: Key): Flow = - flow { - versionTracker.keyFlow(key).collect { - emit(reader(key)) - } - } - - suspend fun flowWriter( - key: Key, - value: Output, - ) { - writer(key, value) - versionTracker.invalidate(key) - } - - suspend fun flowDelete(key: Key) { - delete?.let { - it(key) - versionTracker.invalidate(key) - } - } -} - -fun SimplePersisterAsFlowable.asSourceOfTruth() = - SourceOfTruth.of( - reader = ::flowReader, - writer = ::flowWriter, - delete = ::flowDelete.takeIf { supportsDelete }, - ) - -/** - * helper class which provides Flows for Keys that can be tracked. - */ -internal class KeyTracker { - private val lock = Mutex() - - // list of open key flows - private val flows = mutableMapOf() - - // for testing - internal fun activeKeyCount() = flows.size - - /** - * invalidates the given key. If there are flows returned from [keyFlow] for the given [key], - * they'll receive a new emission - */ - suspend fun invalidate(key: Key) { - lock.withLock { - flows[key] - }?.flow?.emit(Unit) - } - - /** - * Returns a Flow that emits once and then every time the given [key] is invalidated via - * [invalidate] - */ - suspend fun keyFlow(key: Key): Flow { - // it is important to allocate KeyFlow lazily (ony when the returned flow is collected - // from). Otherwise, we might just create many of them that are never observed hence never - // cleaned up - return flow { - val keyFlow = - lock.withLock { - flows.getOrPut(key) { KeyFlow() }.also { - it.acquire() - } - } - emit(Unit) - try { - emitAll(keyFlow.flow) - } finally { - withContext(NonCancellable) { - lock.withLock { - if (keyFlow.release()) { - flows.remove(key) - } - } - } - } - } - } - - /** - * A data structure to count how many active flows we have on this flow - */ - private class KeyFlow { - val flow = - MutableSharedFlow( - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - private var collectors: Int = 0 - - fun acquire() { - collectors++ - } - - fun release() = (--collectors) == 0 - } -} - -fun InMemoryPersister.asFlowable() = - SimplePersisterAsFlowable( - reader = this::read, - writer = this::write, - ) diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/FakeFetcher.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/FakeFetcher.kt deleted file mode 100644 index dd8c88efe..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/FakeFetcher.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2019 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.mobilenativefoundation.store.store5.util - -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import kotlin.test.assertEquals - -class FakeFetcher( - private vararg val responses: Pair, -) : Fetcher { - private var index = 0 - override val name: String? = null - override val fallback: Fetcher? = null - - override fun invoke(key: Key): Flow> { - if (index >= responses.size) { - throw AssertionError("unexpected fetch request") - } - val pair = responses[index++] - assertEquals(pair.first, key) - return flowOf(FetcherResult.Data(pair.second)) - } -} - -class FakeFlowingFetcher( - private vararg val responses: Pair, -) : Fetcher { - override val name: String? = null - override val fallback: Fetcher? = null - - override fun invoke(key: Key) = - flow { - responses.filter { - it.first == key - }.forEach { - // we delay here to avoid collapsing fetcher values, otherwise, there is a - // possibility that consumer won't be fast enough to get both values before new - // value overrides the previous one. - delay(1) - emit(FetcherResult.Data(it.second)) - } - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/InMemoryPersister.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/InMemoryPersister.kt deleted file mode 100644 index 0ba8d1f32..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/InMemoryPersister.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.mobilenativefoundation.store.store5.util - -import org.mobilenativefoundation.store.store5.SourceOfTruth - -/** - * An in-memory non-flowing persister for testing. - */ -open class InMemoryPersister { - private val data = mutableMapOf() - var preWriteCallback: (suspend (key: Key, value: Output) -> Output)? = null - var postReadCallback: (suspend (key: Key, value: Output?) -> Output?)? = null - - @Suppress("RedundantSuspendModifier") // for function reference - suspend fun read(key: Key): Output? { - val value = data[key] - postReadCallback?.let { - return it(key, value) - } - return value - } - - @Suppress("RedundantSuspendModifier") // for function reference - open suspend fun write(key: Key, output: Output) { - val value = preWriteCallback?.invoke(key, output) ?: output - data[key] = value - } - - @Suppress("RedundantSuspendModifier") // for function reference - suspend fun deleteByKey(key: Key) { - data.remove(key) - } - - @Suppress("RedundantSuspendModifier") // for function reference - suspend fun deleteAll() { - data.clear() - } - - fun peekEntry(key: Key): Output? { - return data[key] - } -} - -fun InMemoryPersister.asSourceOfTruth() = - SourceOfTruth.of( - nonFlowReader = ::read, - writer = ::write, - delete = ::deleteByKey, - deleteAll = ::deleteAll, - ) diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestApi.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestApi.kt deleted file mode 100644 index 778021e9d..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestApi.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.mobilenativefoundation.store.store5.util - -internal interface TestApi { - fun get( - key: Key, - fail: Boolean = false, - ttl: Long? = null, - ): Network? - - fun post( - key: Key, - value: Output, - fail: Boolean = false, - ): Response -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestStoreExt.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestStoreExt.kt deleted file mode 100644 index 87e3f1679..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/TestStoreExt.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.mobilenativefoundation.store.store5.util - -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.first -import org.mobilenativefoundation.store.store5.Store -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.StoreReadResponse -import org.mobilenativefoundation.store.store5.impl.operators.mapIndexed - -/** - * Helper factory that will return [StoreReadResponse.Data] for [key] - * if it is cached otherwise will return fresh/network data (updating your caches) - */ -suspend fun Store.getData(key: Key) = - stream( - StoreReadRequest.cached(key, refresh = false), - ).filterNot { - it is StoreReadResponse.Loading - }.mapIndexed { index, value -> - value - }.first().let { - StoreReadResponse.Data(it.requireData(), it.origin) - } diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NoteCollections.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NoteCollections.kt deleted file mode 100644 index 8482cff6f..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NoteCollections.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.store5.util.model.NoteData - -internal object NoteCollections { - object Keys { - const val OneAndTwo = "ONE_AND_TWO" - } - - val OneAndTwo = NoteData.Collection(listOf(Notes.One, Notes.Two)) -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/Notes.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/Notes.kt deleted file mode 100644 index cfc4c6e75..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/Notes.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.store5.util.model.Note - -internal object Notes { - val One = Note("1", "Title-1", "Content-1") - val Two = Note("2", "Title-2", "Content-2") - val Three = Note("3", "Title-3", "Content-3") - val Four = Note("4", "Title-4", "Content-4") - val Five = Note("5", "Title-5", "Content-5") - val Six = Note("6", "Title-6", "Content-6") - val Seven = Note("7", "Title-7", "Content-7") - val Eight = Note("8", "Title-8", "Content-8") - val Nine = Note("9", "Title-9", "Content-9") - val Ten = Note("10", "Title-10", "Content-10") -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt deleted file mode 100644 index f7de9e21b..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesApi.kt +++ /dev/null @@ -1,60 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.store5.util.TestApi -import org.mobilenativefoundation.store.store5.util.model.InputNote -import org.mobilenativefoundation.store.store5.util.model.NetworkNote -import org.mobilenativefoundation.store.store5.util.model.NoteData -import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse - -internal class NotesApi : TestApi { - internal val db = mutableMapOf() - - init { - seed() - } - - override fun get( - key: NotesKey, - fail: Boolean, - ttl: Long?, - ): NetworkNote { - if (fail) { - throw Exception() - } - - val networkNote = db[key]!! - return if (ttl != null) { - networkNote.copy(ttl = ttl) - } else { - networkNote - } - } - - override fun post( - key: NotesKey, - value: InputNote, - fail: Boolean, - ): NotesWriteResponse { - if (fail) { - throw Exception() - } - - db[key] = NetworkNote(value.data) - - return NotesWriteResponse(key, true) - } - - private fun seed() { - db[NotesKey.Single(Notes.One.id)] = NetworkNote(NoteData.Single(Notes.One)) - db[NotesKey.Single(Notes.Two.id)] = NetworkNote(NoteData.Single(Notes.Two)) - db[NotesKey.Single(Notes.Three.id)] = NetworkNote(NoteData.Single(Notes.Three)) - db[NotesKey.Single(Notes.Four.id)] = NetworkNote(NoteData.Single(Notes.Four)) - db[NotesKey.Single(Notes.Five.id)] = NetworkNote(NoteData.Single(Notes.Five)) - db[NotesKey.Single(Notes.Six.id)] = NetworkNote(NoteData.Single(Notes.Six)) - db[NotesKey.Single(Notes.Seven.id)] = NetworkNote(NoteData.Single(Notes.Seven)) - db[NotesKey.Single(Notes.Eight.id)] = NetworkNote(NoteData.Single(Notes.Eight)) - db[NotesKey.Single(Notes.Nine.id)] = NetworkNote(NoteData.Single(Notes.Nine)) - db[NotesKey.Single(Notes.Ten.id)] = NetworkNote(NoteData.Single(Notes.Ten)) - db[NotesKey.Collection(NoteCollections.Keys.OneAndTwo)] = NetworkNote(NoteCollections.OneAndTwo) - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesBookkeeping.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesBookkeeping.kt deleted file mode 100644 index 61c342820..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesBookkeeping.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -class NotesBookkeeping { - private val log: MutableMap = mutableMapOf() - - fun setLastFailedSync( - key: NotesKey, - timestamp: Long, - fail: Boolean = false, - ): Boolean { - if (fail) { - throw Exception() - } - log[key] = timestamp - return true - } - - fun getLastFailedSync( - key: NotesKey, - fail: Boolean = false, - ): Long? { - if (fail) { - throw Exception() - } - - return log[key] - } - - fun clear( - key: NotesKey, - fail: Boolean = false, - ): Boolean { - if (fail) { - throw Exception() - } - log.remove(key) - return true - } - - fun clear(fail: Boolean = false): Boolean { - if (fail) { - throw Exception() - } - log.clear() - return true - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesConverterProvider.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesConverterProvider.kt deleted file mode 100644 index ee89216da..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesConverterProvider.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.impl.extensions.inHours -import org.mobilenativefoundation.store.store5.util.model.InputNote -import org.mobilenativefoundation.store.store5.util.model.NetworkNote -import org.mobilenativefoundation.store.store5.util.model.OutputNote - -internal class NotesConverterProvider { - fun provide(): Converter = - Converter.Builder() - .fromOutputToLocal { value -> InputNote(data = value.data, ttl = value.ttl) } - .fromNetworkToLocal { value: NetworkNote -> - InputNote( - data = value.data, - ttl = value.ttl ?: inHours(12), - ) - } - .build() -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt deleted file mode 100644 index 063a0a3a9..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesDatabase.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.store5.util.model.InputNote -import org.mobilenativefoundation.store.store5.util.model.OutputNote - -internal class NotesDatabase { - private val db: MutableMap = mutableMapOf() - - fun put( - key: NotesKey, - input: InputNote, - fail: Boolean = false, - ): Boolean { - if (fail) { - throw Exception() - } - - db[key] = OutputNote(input.data, input.ttl ?: 0) - return true - } - - fun get( - key: NotesKey, - fail: Boolean = false, - ): OutputNote? { - if (fail) { - throw Exception() - } - - return db[key] - } - - fun clear( - key: NotesKey, - fail: Boolean = false, - ): Boolean { - if (fail) { - throw Exception() - } - db.remove(key) - return true - } - - fun clear(fail: Boolean = false): Boolean { - if (fail) { - throw Exception() - } - db.clear() - return true - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesKey.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesKey.kt deleted file mode 100644 index 25058d3b6..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesKey.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -sealed class NotesKey { - data class Single(val id: String) : NotesKey() - - data class Collection(val id: String) : NotesKey() -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt deleted file mode 100644 index 35529028e..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesUpdaterProvider.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.store5.Updater -import org.mobilenativefoundation.store.store5.UpdaterResult -import org.mobilenativefoundation.store.store5.util.model.InputNote -import org.mobilenativefoundation.store.store5.util.model.NotesWriteResponse -import org.mobilenativefoundation.store.store5.util.model.OutputNote - -internal class NotesUpdaterProvider(private val api: NotesApi) { - fun provide(): Updater = - Updater.by( - post = { key, input -> - val response = api.post(key, InputNote(input.data, input.ttl ?: 0)) - if (response.ok) { - UpdaterResult.Success.Typed(response) - } else { - UpdaterResult.Error.Message("Failed to sync") - } - }, - ) -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt deleted file mode 100644 index bf7296f22..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/NotesValidator.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake - -import org.mobilenativefoundation.store.store5.Validator -import org.mobilenativefoundation.store.store5.impl.extensions.now -import org.mobilenativefoundation.store.store5.util.model.OutputNote - -internal class NotesValidator(private val expiration: Long = now()) : Validator { - override suspend fun isValid(item: OutputNote): Boolean = - when { - item.ttl == 0L -> true - else -> item.ttl > expiration - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/HardcodedPages.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/HardcodedPages.kt deleted file mode 100644 index d7c7c9441..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/HardcodedPages.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake.fallback - -class HardcodedPages { - val name = "HardcodedPages" - val db = mutableMapOf() - - init { - seed() - } - - private fun seed() { - db["1"] = Page.Data("1") - db["2"] = Page.Data("2") - db["3"] = Page.Data("3") - } - - fun get(key: String) = db[key] ?: throw Exception() -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/Page.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/Page.kt deleted file mode 100644 index ccc456277..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/Page.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake.fallback - -sealed class Page { - data class Data( - val title: String, - val ttl: Long? = null, - ) : Page() - - object Empty : Page() -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PagesDatabase.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PagesDatabase.kt deleted file mode 100644 index 3c7a0f245..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PagesDatabase.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake.fallback - -class PagesDatabase { - private val db: MutableMap = mutableMapOf() - - fun put( - key: String, - input: Page, - ): Boolean { - db[key] = input - return true - } - - fun get(key: String): Page? = db[key] -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PrimaryPagesApi.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PrimaryPagesApi.kt deleted file mode 100644 index 7c8126702..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/PrimaryPagesApi.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake.fallback - -class PrimaryPagesApi { - val name = "PrimaryPagesApi" - - internal val db = mutableMapOf() - - init { - seed() - } - - private fun seed() { - db["1"] = Page.Data("1") - db["2"] = Page.Data("2") - db["3"] = Page.Data("3") - } - - fun fetch( - key: String, - fail: Boolean, - ttl: Long?, - ): Page { - if (fail) { - throw Exception() - } - - return db[key] ?: Page.Empty - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/SecondaryPagesApi.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/SecondaryPagesApi.kt deleted file mode 100644 index 59c3fb398..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/fake/fallback/SecondaryPagesApi.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.fake.fallback - -class SecondaryPagesApi() { - val name: String = "SecondaryPagesApi" - internal val db = mutableMapOf() - - init { - seed() - } - - fun get(key: String) = db[key] ?: throw Exception() - - private fun seed() { - db["1"] = Page.Data("1") - db["2"] = Page.Data("2") - db["3"] = Page.Data("3") - } -} diff --git a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt b/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt deleted file mode 100644 index cce0fc5fa..000000000 --- a/store/src/commonTest/kotlin/org/mobilenativefoundation/store/store5/util/model/NoteData.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.mobilenativefoundation.store.store5.util.model - -import org.mobilenativefoundation.store.store5.util.fake.NotesKey - -internal sealed class NoteData { - data class Single(val item: Note) : NoteData() - - data class Collection(val items: List) : NoteData() -} - -internal data class NotesWriteResponse( - val key: NotesKey, - val ok: Boolean, -) - -internal data class NetworkNote( - val data: NoteData? = null, - val ttl: Long? = null, -) - -internal data class InputNote( - val data: NoteData? = null, - val ttl: Long? = null, -) - -internal data class OutputNote( - val data: NoteData? = null, - val ttl: Long, -) - -internal data class Note( - val id: String, - val title: String, - val content: String, -) diff --git a/store/src/iosMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.ios.kt b/store/src/iosMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.ios.kt deleted file mode 100644 index 585ab1dc7..000000000 --- a/store/src/iosMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.ios.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.alloc -import kotlinx.cinterop.memScoped -import kotlinx.cinterop.ptr -import platform.posix.gettimeofday -import platform.posix.timeval - -@OptIn(ExperimentalForeignApi::class) -internal actual fun currentTimeMillis(): Long = - memScoped { - val tv = alloc() - gettimeofday(tv.ptr, null) - (tv.tv_sec.toLong() * 1000L) + (tv.tv_usec.toLong() / 1000L) - } diff --git a/store/src/jsMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.js.kt b/store/src/jsMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.js.kt deleted file mode 100644 index 5ed24ef65..000000000 --- a/store/src/jsMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.js.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -@Suppress("UnsafeCastFromDynamic") -internal actual fun currentTimeMillis(): Long = - kotlin.js.Date - .now() - .toLong() diff --git a/store/src/jvmMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.jvm.kt b/store/src/jvmMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.jvm.kt deleted file mode 100644 index 08d6e9b49..000000000 --- a/store/src/jvmMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.jvm.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -internal actual fun currentTimeMillis(): Long = System.currentTimeMillis() diff --git a/store/src/linuxX64Main/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.linuxX64.kt b/store/src/linuxX64Main/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.linuxX64.kt deleted file mode 100644 index 585ab1dc7..000000000 --- a/store/src/linuxX64Main/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.linuxX64.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.alloc -import kotlinx.cinterop.memScoped -import kotlinx.cinterop.ptr -import platform.posix.gettimeofday -import platform.posix.timeval - -@OptIn(ExperimentalForeignApi::class) -internal actual fun currentTimeMillis(): Long = - memScoped { - val tv = alloc() - gettimeofday(tv.ptr, null) - (tv.tv_sec.toLong() * 1000L) + (tv.tv_usec.toLong() / 1000L) - } diff --git a/store/src/wasmJsMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.wasmJs.kt b/store/src/wasmJsMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.wasmJs.kt deleted file mode 100644 index a73410eb4..000000000 --- a/store/src/wasmJsMain/kotlin/org/mobilenativefoundation/store/store5/impl/extensions/Clock.wasmJs.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.mobilenativefoundation.store.store5.impl.extensions - -@JsFun("() => Date.now()") -private external fun dateNow(): Double - -internal actual fun currentTimeMillis(): Long = dateNow().toLong() diff --git a/store6-benchmarks/README.md b/store6-benchmarks/README.md new file mode 100644 index 000000000..c6a67aa6d --- /dev/null +++ b/store6-benchmarks/README.md @@ -0,0 +1,116 @@ +# store6-benchmarks + +`store6-benchmarks` is an unpublished JVM harness for Store v6. It measures +end-to-end collector attachment plus write-to-final-observation under +a controlled schedule against the raw Source of Truth flow, and records +structural-plus-measured evidence for telemetry unset versus configured-noop +overhead. It is neither a published artifact nor a public API. + +## Run + +From the repository root: + +```shell +./gradlew :store6-benchmarks:benchmark +./gradlew :store6-benchmarks:smokeBenchmark +./gradlew :store6-benchmarks:calibrateBenchmark +``` + +`benchmark` is the default local profile. `smokeBenchmark` is the short, +report-only CI shape. `calibrateBenchmark` uses three forks and is the only +profile whose results may support a performance-target proposal when run on a +documented, quiet, plugged-in machine. + +Result JSON is discovered recursively beneath +`store6-benchmarks/build/reports/benchmarks/`. The timestamped layout observed +with `kotlinx-benchmark` 0.4.17 is evidence, not a stable path contract. + +Start from a clean report directory. From the repository root, this snippet +requires exactly one non-empty, structurally valid JSON result before +summarizing it. It sorts parameter names before rendering them. + +```shell +reports_dir="store6-benchmarks/build/reports/benchmarks" +report_count="$( + find "$reports_dir" -type f -name '*.json' -print | + wc -l | + tr -d ' ' +)" +test "$report_count" -eq 1 +report="$(find "$reports_dir" -type f -name '*.json' -print)" +jq -e ' + type == "array" and + length > 0 and + all(.[]; + (.benchmark | type == "string" and length > 0) and + (.primaryMetric.score | type == "number") and + (.primaryMetric.scoreUnit | type == "string" and length > 0) + ) +' "$report" >/dev/null +jq -r ' + .[] | + [ + .benchmark, + ((.params // {}) | + to_entries | + sort_by(.key) | + map("\(.key)=\(.value)") | + join(",")), + (.primaryMetric.score | tostring), + .primaryMetric.scoreUnit + ] | + @tsv +' "$report" +``` + +Treat a clean invocation as valid only when it produces one non-empty JSON +array with the expected benchmark inventory and numeric primary metrics. + +## What the numbers mean + +1. **The measured ratio.** The metric is the `storeStream` / `rawSotFlow` + average-time ratio for an end-to-end timed invocation. Within each invocation, W=1000 + begins only after every collector receives a public result and observes one + epoch-unique readiness-marker write. That precondition is outside W but + inside the timed operation. The score includes collector launch, attachment, + readiness, the W schedule, and final observation. It is not pure W-only + latency or per-emission cost. Both sides may conflate intermediate writes. + `paced=true` cooperatively yields the writer; it is not an acknowledgement or + a guarantee that all writes are observed. `paced=false` is + burst/conflation. +2. **Headline and topology boundary.** `collectors=1` is the engine-overhead + headline because both sides use `FakeSourceOfTruth` with matching reader + multiplicity and common write cost. `collectors=8` is fan-out/topology data: + raw opens eight reader chains while Store shares one upstream and fans out. + It does not isolate engine overhead. +3. **Dispatch hops count.** Store's `Dispatchers.Default` engine hops are part + of Store cost. The raw side cooperates on `runBlocking`. +4. **The telemetry-off zero-overhead claim is structural plus measured.** + Structural tests and code establish that unset telemetry remains null and + allocates no fetch mark. + `none`-vs-`noop` estimates incremental configured-noop overhead relative to + unset: non-null branches, the mark, and virtual no-op calls. It does not prove + literal zero cost or bound total machinery against a telemetry-free engine. + The ABBA allocation probe covers only the caller-thread resident path; a + local JMH GC profiler, when available, covers cross-thread allocations. +5. **Hosted CI is smoke-grade.** No hosted number may support a performance + target. Only a local quiet-machine `calibrate` result may support a target + proposal. +6. **No numeric CI gate exists.** Until a numeric performance target is + adopted, workflows validate execution and schema only. They contain no + performance threshold. +7. **Invocation isolation.** Every multi-write stream invocation uses + epoch-unique readiness and sentinel values. Stores close per thread-scoped + trial; cold stores close per invocation. + +## CI boundary + +The blocking `:store6-benchmarks:build` step in +`.github/workflows/store6.yml` compiles the harness and executes every benchmark +body once through smoke tests. It is a rot guard, not a performance gate. + +`.github/workflows/store6-benchmarks.yml` runs `smokeBenchmark`, validates the +result shape, and uploads the JSON in a non-blocking, report-only measurement +lane. It remains outside the exact-head-green release gate. No workflow may +assert a timing or allocation threshold until a numeric performance target is +adopted. diff --git a/store6-benchmarks/build.gradle.kts b/store6-benchmarks/build.gradle.kts new file mode 100644 index 000000000..5fe1a5688 --- /dev/null +++ b/store6-benchmarks/build.gradle.kts @@ -0,0 +1,61 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.kotlin.plugin.allopen") version libs.versions.baseKotlin.get() + alias(libs.plugins.kotlinx.benchmark) +} + +kotlin { jvmToolchain(11) } + +// JMH requires @State classes to be non-final. The kotlinx.benchmark annotations typealias to +// JMH's on the JVM target, so allopen keys on the JMH FQN (kotlinx-benchmark README, Kotlin/JVM +// setup). Benchmark classes are additionally declared `open` for clarity. +allOpen { + annotation("org.openjdk.jmh.annotations.State") +} + +dependencies { + implementation(projects.store6Core) + // FakeSourceOfTruth: the shared, contract-kit-passing SoT on BOTH sides of every ratio. + implementation(projects.store6Testing) + implementation(libs.kotlinx.benchmark.runtime) + testImplementation(kotlin("test")) +} + +benchmark { + configurations { + named("main") { + warmups = 5 + iterations = 10 + iterationTime = 1 + iterationTimeUnit = "s" + mode = "avgt" + outputTimeUnit = "us" + } + // Fast, report-only signal; never a hard performance gate. + register("smoke") { + warmups = 2 + iterations = 3 + iterationTime = 500 + iterationTimeUnit = "ms" + mode = "avgt" + outputTimeUnit = "us" + } + // Longer calibration profile for an otherwise quiet machine. + register("calibrate") { + warmups = 8 + iterations = 15 + iterationTime = 2 + iterationTimeUnit = "s" + mode = "avgt" + outputTimeUnit = "us" + advanced("jvmForks", "3") + } + } + targets { + register("main") { + this as kotlinx.benchmark.gradle.JvmBenchmarkTarget + // PIN: JMH backend pinned for reproducibility. + jmhVersion = "1.37" + } + } +} diff --git a/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/BenchKey.kt b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/BenchKey.kt new file mode 100644 index 000000000..6a24e0802 --- /dev/null +++ b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/BenchKey.kt @@ -0,0 +1,12 @@ +package org.mobilenativefoundation.store6.benchmarks + +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +internal val BENCH_NAMESPACE = StoreNamespace("bench") + +internal class BenchKey(private val id: String) : StoreKey { + override val namespace: StoreNamespace = BENCH_NAMESPACE + + override fun canonicalId(): String = id +} diff --git a/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/ColdStartBenchmark.kt b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/ColdStartBenchmark.kt new file mode 100644 index 000000000..7e1bd60d2 --- /dev/null +++ b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/ColdStartBenchmark.kt @@ -0,0 +1,51 @@ +package org.mobilenativefoundation.store6.benchmarks + +import kotlinx.benchmark.Benchmark +import kotlinx.benchmark.Blackhole +import kotlinx.benchmark.Scope +import kotlinx.benchmark.Setup +import kotlinx.benchmark.State +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth + +/** + * Supplementary: quickstart-shape spin-up. The store side deliberately includes builder cost, + * engine construction, and close() per invocation — that is the measurand (what a fresh + * store-per-screen pattern would pay). The raw side is a fresh reader collection's first row. + * Not part of the METRIC-1 headline ratio. + */ +@OptIn(ExperimentalStoreApi::class) +@State(Scope.Thread) +open class ColdStartBenchmark { + private lateinit var sot: FakeSourceOfTruth + private val key = BenchKey("cold") + + @Setup + fun setup() { + sot = FakeSourceOfTruth() + runBlocking { sot.write(key, "seed") } + } + + @Benchmark + fun storeColdConstructAndFirstData(bh: Blackhole) = runBlocking { + val store = store { + fetcher { error("unreachable: all reads use Freshness.LocalOnly") } + persistence(sot) + } + try { + bh.consume(store.stream(key, Freshness.LocalOnly).first { it is StoreResult.Data }) + } finally { + store.close() + } + } + + @Benchmark + fun rawColdFirstRead(bh: Blackhole) = runBlocking { + bh.consume(sot.reader(key).first()) + } +} diff --git a/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/GetPathBenchmark.kt b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/GetPathBenchmark.kt new file mode 100644 index 000000000..cf1bc688a --- /dev/null +++ b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/GetPathBenchmark.kt @@ -0,0 +1,56 @@ +package org.mobilenativefoundation.store6.benchmarks + +import kotlinx.benchmark.Benchmark +import kotlinx.benchmark.Blackhole +import kotlinx.benchmark.Scope +import kotlinx.benchmark.Setup +import kotlinx.benchmark.State +import kotlinx.benchmark.TearDown +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth + +/** + * Supplementary: the one-shot resident read path. Between invocations the key quiesces, so ops + * exercise the resident/idle-revive path (maxIdleKeys default 128 keeps the engine parked, never + * destroyed) — stated in the first-data doc alongside the number. + */ +@OptIn(ExperimentalStoreApi::class) +@State(Scope.Thread) +open class GetPathBenchmark { + private lateinit var sot: FakeSourceOfTruth + private lateinit var store: Store + private val key = BenchKey("get") + + @Setup + fun setup() { + sot = FakeSourceOfTruth() + store = store { + fetcher { error("unreachable: all reads use Freshness.LocalOnly") } + persistence(sot) + } + runBlocking { + sot.write(key, "seed") + store.get(key, Freshness.LocalOnly) + } + } + + @TearDown + fun tearDown() { + store.close() + } + + @Benchmark + fun storeGetResident(bh: Blackhole) = runBlocking { + bh.consume(store.get(key, Freshness.LocalOnly)) + } + + @Benchmark + fun rawReaderFirst(bh: Blackhole) = runBlocking { + bh.consume(sot.reader(key).first()) + } +} diff --git a/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/NoopTelemetry.kt b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/NoopTelemetry.kt new file mode 100644 index 000000000..86f9fe8da --- /dev/null +++ b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/NoopTelemetry.kt @@ -0,0 +1,14 @@ +package org.mobilenativefoundation.store6.benchmarks + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry + +/** + * Configured-but-empty sink. Every hook keeps its interface-default no-op body. Comparing a store + * built with this sink to one with telemetry unset estimates incremental configured-noop overhead + * relative to the null fast path: non-null branches, the fetch-duration mark in + * KeyEngine.launchFetch, and virtual dispatch into empty bodies. + */ +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +internal object NoopTelemetry : StoreTelemetry diff --git a/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/StreamEmissionBenchmark.kt b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/StreamEmissionBenchmark.kt new file mode 100644 index 000000000..8bd6055bd --- /dev/null +++ b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/StreamEmissionBenchmark.kt @@ -0,0 +1,149 @@ +package org.mobilenativefoundation.store6.benchmarks + +import kotlinx.benchmark.Benchmark +import kotlinx.benchmark.Blackhole +import kotlinx.benchmark.Param +import kotlinx.benchmark.Scope +import kotlinx.benchmark.Setup +import kotlinx.benchmark.State +import kotlinx.benchmark.TearDown +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth + +/** + * METRIC-1: stream-emission overhead versus the raw SoT flow. + * + * Both sides observe the SAME FakeSourceOfTruth class under the SAME write schedule, awaiting the + * SAME epoch-unique sentinel. Within each timed invocation, before workload writes, every + * collector first receives a public emission and then observes an epoch-unique attachment marker. + * That marker is one additional common write/observation per invocation outside the W=1000 + * workload but inside the timed operation. It is an attachment precondition, not workload data, + * and proves the Store's long-lived reader/fan-out pipeline is attached before W begins. + * + * With collectors=1, reader multiplicity matches and storeStream/rawSotFlow is the METRIC-1 + * engine-overhead headline: registry, reader pipeline, planning, conflation, projection, telemetry + * null-guard, and dispatch hops. The engine runs on Dispatchers.Default while the raw side is + * cooperative on the runBlocking thread; that asymmetry is Store cost and stays in the ratio. + * + * collectors=8 is a separately interpreted fan-out/topology cell: raw opens eight FakeSourceOfTruth + * reader chains while Store shares one upstream and fans out. It is useful end-to-end scaling data, + * not an isolated engine-overhead ratio. + * + * The reported score includes collector launch, attachment, readiness, the W schedule, and final + * observation. It is an end-to-end attach-plus-schedule measurand, not pure W-only latency or + * per-emission unit cost. Both sides may conflate arbitrary intermediate writes. paced=true is a + * cooperatively yielded writer schedule, not an acknowledgement or per-emission guarantee; + * paced=false is the burst/conflation schedule. The two bracket real workloads. + */ +@OptIn(ExperimentalStoreApi::class) +@State(Scope.Thread) +open class StreamEmissionBenchmark { + @Param("1000") + var writes: Int = 0 + + @Param("1", "8") + var collectors: Int = 0 + + @Param("false", "true") + var paced: Boolean = false + + private lateinit var sot: FakeSourceOfTruth + private lateinit var store: Store + private val key = BenchKey("stream") + private var epoch = 0L + + @Setup + fun setup() { + sot = FakeSourceOfTruth() + store = store { + fetcher { error("unreachable: all reads use Freshness.LocalOnly") } + persistence(sot) + } + runBlocking { sot.write(key, "seed") } + } + + @TearDown + fun tearDown() { + store.close() + } + + @Benchmark + fun rawSotFlow(bh: Blackhole) = runBlocking { + epoch += 1 + val readiness = "ready-$epoch" + val sentinel = "v-$epoch-$writes" + coroutineScope { + val initialReadies = List(collectors) { CompletableDeferred() } + val attachedReadies = List(collectors) { CompletableDeferred() } + repeat(collectors) { c -> + launch { + var first = true + bh.consume( + sot.reader(key).first { + if (first) { + first = false + initialReadies[c].complete(Unit) + } + if (it == readiness) attachedReadies[c].complete(Unit) + it == sentinel + }, + ) + } + } + initialReadies.forEach { it.await() } + sot.write(key, readiness) + attachedReadies.forEach { it.await() } + runSchedule() + } + } + + @Benchmark + fun storeStream(bh: Blackhole) = runBlocking { + epoch += 1 + val readiness = "ready-$epoch" + val sentinel = "v-$epoch-$writes" + coroutineScope { + val initialReadies = List(collectors) { CompletableDeferred() } + val attachedReadies = List(collectors) { CompletableDeferred() } + repeat(collectors) { c -> + launch { + var first = true + bh.consume( + store.stream(key, Freshness.LocalOnly).first { + if (first) { + first = false + initialReadies[c].complete(Unit) + } + if (it is StoreResult.Data && it.value == readiness) { + attachedReadies[c].complete(Unit) + } + it is StoreResult.Data && it.value == sentinel + }, + ) + } + } + initialReadies.forEach { it.await() } + sot.write(key, readiness) + attachedReadies.forEach { it.await() } + runSchedule() + } + } + + /** The identical write schedule both benchmark methods run after all collectors attach. */ + private suspend fun runSchedule() { + for (i in 1..writes) { + sot.write(key, "v-$epoch-$i") + if (paced) yield() + } + } +} diff --git a/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt new file mode 100644 index 000000000..494272c9b --- /dev/null +++ b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/SubscriptionChurnBenchmark.kt @@ -0,0 +1,54 @@ +package org.mobilenativefoundation.store6.benchmarks + +import kotlinx.benchmark.Benchmark +import kotlinx.benchmark.Blackhole +import kotlinx.benchmark.Scope +import kotlinx.benchmark.Setup +import kotlinx.benchmark.State +import kotlinx.benchmark.TearDown +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth + +/** + * Supplementary: attach -> first Data -> cancel against a long-lived store, repeatedly. This is + * the registry/reader-pipeline lifecycle that READER_PIPELINE_GRACE_MILLIS parks between + * collections. The raw side is the same churn against the bare reader. + */ +@OptIn(ExperimentalStoreApi::class) +@State(Scope.Thread) +open class SubscriptionChurnBenchmark { + private lateinit var sot: FakeSourceOfTruth + private lateinit var store: Store + private val key = BenchKey("churn") + + @Setup + fun setup() { + sot = FakeSourceOfTruth() + store = store { + fetcher { error("unreachable: all reads use Freshness.LocalOnly") } + persistence(sot) + } + runBlocking { sot.write(key, "seed") } + } + + @TearDown + fun tearDown() { + store.close() + } + + @Benchmark + fun storeAttachFirstDataCancel(bh: Blackhole) = runBlocking { + bh.consume(store.stream(key, Freshness.LocalOnly).first { it is StoreResult.Data }) + } + + @Benchmark + fun rawAttachFirstRowCancel(bh: Blackhole) = runBlocking { + bh.consume(sot.reader(key).first { it != null }) + } +} diff --git a/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt new file mode 100644 index 000000000..1625a81c4 --- /dev/null +++ b/store6-benchmarks/src/main/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryOverheadBenchmark.kt @@ -0,0 +1,123 @@ +package org.mobilenativefoundation.store6.benchmarks + +import kotlinx.benchmark.Benchmark +import kotlinx.benchmark.Blackhole +import kotlinx.benchmark.Param +import kotlinx.benchmark.Scope +import kotlinx.benchmark.Setup +import kotlinx.benchmark.State +import kotlinx.benchmark.TearDown +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth + +/** + * The measured half of the telemetry "zero cost when unset" claim; the allocation-count half is + * TelemetryAllocationProbe (see StoreTelemetryTest.kt:114). + * + * This evidence is measured plus structural, not a literal differential against a telemetry-free + * engine. Structural inspection and tests establish that telemetry=none leaves the install point + * null, each call site takes its null fast path, and KeyEngine.launchFetch allocates no + * fetch-duration mark. telemetry=noop installs NoopTelemetry, so this benchmark estimates the + * incremental configured-noop overhead relative to that unset/null fast path: non-null branches, + * the fetch-duration mark, and virtual calls into no-op bodies. There is no seam-less engine to + * compare, so the delta neither proves literal zero cost nor bounds total telemetry machinery cost + * relative to such an engine. + * + * fetchGet: full fetch cycle per op (onFetchStarted + mark + onFetchSucceeded + onServe), via + * MustBeFresh against a constant fetcher on the DSL-default in-memory SoT (public builder path). + * residentServe: resident LocalOnly get (onServe only). streamEmissions: each timed invocation + * launches one collector, waits for its first public result and an epoch-unique readiness marker, + * then runs a 100-write cooperatively yielded schedule through the attached stream. Its score + * includes that precondition, the schedule, and final observation. Both variants may conflate + * intermediate writes, and onServe runs once per public delivery. The none/noop pair uses the same + * schedule. + */ +@OptIn(ExperimentalStoreApi::class) +@State(Scope.Thread) +open class TelemetryOverheadBenchmark { + @Param("none", "noop") + var telemetry: String = "none" + + private lateinit var sot: FakeSourceOfTruth + private lateinit var fetchStore: Store + private lateinit var localStore: Store + private val key = BenchKey("telemetry") + private var epoch = 0L + + @Setup + fun setup() { + sot = FakeSourceOfTruth() + fetchStore = store { + fetcher { "fetched" } + if (telemetry == "noop") telemetry(NoopTelemetry) + } + localStore = store { + fetcher { error("unreachable: all localStore reads use Freshness.LocalOnly") } + persistence(sot) + if (telemetry == "noop") telemetry(NoopTelemetry) + } + runBlocking { + sot.write(key, "seed") + localStore.get(key, Freshness.LocalOnly) + } + } + + @TearDown + fun tearDown() { + fetchStore.close() + localStore.close() + } + + @Benchmark + fun fetchGet(bh: Blackhole) = runBlocking { + bh.consume(fetchStore.get(key, Freshness.MustBeFresh)) + } + + @Benchmark + fun residentServe(bh: Blackhole) = runBlocking { + bh.consume(localStore.get(key, Freshness.LocalOnly)) + } + + @Benchmark + fun streamEmissions(bh: Blackhole) = runBlocking { + epoch += 1 + val readiness = "ready-$epoch" + val sentinel = "v-$epoch-100" + coroutineScope { + val initialReady = CompletableDeferred() + val attachedReady = CompletableDeferred() + launch { + var first = true + bh.consume( + localStore.stream(key, Freshness.LocalOnly).first { + if (first) { + first = false + initialReady.complete(Unit) + } + if (it is StoreResult.Data && it.value == readiness) { + attachedReady.complete(Unit) + } + it is StoreResult.Data && it.value == sentinel + }, + ) + } + initialReady.await() + sot.write(key, readiness) + attachedReady.await() + for (i in 1..100) { + sot.write(key, "v-$epoch-$i") + yield() + } + } + } +} diff --git a/store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/HarnessSmokeTest.kt b/store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/HarnessSmokeTest.kt new file mode 100644 index 000000000..dcc547fa0 --- /dev/null +++ b/store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/HarnessSmokeTest.kt @@ -0,0 +1,95 @@ +package org.mobilenativefoundation.store6.benchmarks + +import kotlinx.benchmark.Blackhole +import kotlin.test.Test + +/** + * Executes every benchmark method once with tiny parameters, without the JMH runner. This test is + * what makes the blocking `:store6-benchmarks:build` CI step a rot guard: benchmark code cannot + * silently decay while the measurement lane stays non-blocking. Numbers are not read here. + */ +class HarnessSmokeTest { + // JMH's sanctioned escape hatch for constructing a Blackhole outside the runner; the string is + // JMH API (org.openjdk.jmh.infra.Blackhole's guarded constructor). + private val bh = Blackhole( + "Today's password is swordfish. I understand instantiating Blackholes directly is dangerous.", + ) + + @Test + fun streamEmissionBenchmark_bothSides_runOnce() { + val b = StreamEmissionBenchmark() + b.writes = 8 + b.collectors = 2 + b.paced = true + b.setup() + try { + b.rawSotFlow(bh) + b.storeStream(bh) + } finally { + b.tearDown() + } + } + + @Test + fun streamEmissionBenchmark_burstRegime_runsOnce() { + val b = StreamEmissionBenchmark() + b.writes = 8 + b.collectors = 1 + b.paced = false + b.setup() + try { + b.rawSotFlow(bh) + b.storeStream(bh) + } finally { + b.tearDown() + } + } + + @Test + fun coldStartBenchmark_bothSides_runOnce() { + val b = ColdStartBenchmark() + b.setup() + b.storeColdConstructAndFirstData(bh) + b.rawColdFirstRead(bh) + } + + @Test + fun getPathBenchmark_bothSides_runOnce() { + val b = GetPathBenchmark() + b.setup() + try { + b.storeGetResident(bh) + b.rawReaderFirst(bh) + } finally { + b.tearDown() + } + } + + @Test + fun subscriptionChurnBenchmark_bothSides_runOnce() { + val b = SubscriptionChurnBenchmark() + b.setup() + try { + b.storeAttachFirstDataCancel(bh) + b.rawAttachFirstRowCancel(bh) + } finally { + b.tearDown() + } + } + + @Test + fun telemetryOverheadBenchmark_bothVariants_runOnce() { + for (variant in listOf("none", "noop")) { + val b = TelemetryOverheadBenchmark() + b.telemetry = variant + b.setup() + try { + b.fetchGet(bh) + b.residentServe(bh) + b.streamEmissions(bh) + } finally { + b.tearDown() + } + } + } +} diff --git a/store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt b/store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt new file mode 100644 index 000000000..dcf29b8e8 --- /dev/null +++ b/store6-benchmarks/src/test/kotlin/org/mobilenativefoundation/store6/benchmarks/TelemetryAllocationProbe.kt @@ -0,0 +1,173 @@ +package org.mobilenativefoundation.store6.benchmarks + +import kotlinx.coroutines.runBlocking +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.testing.FakeSourceOfTruth +import java.lang.management.ManagementFactory +import kotlin.test.Test + +/** + * Allocation evidence for the measured-plus-structural zero-cost-when-unset claim: this module + * performs the allocation-count measurement StoreTelemetryTest.kt:114 references. Reports + * CALLER-THREAD allocated bytes/op on the resident-serve path for telemetry-unset vs + * NoopTelemetry-configured stores. + * + * REPORT-ONLY by design: prints a table, asserts nothing numeric (no threshold is defined yet), and + * skips gracefully off HotSpot. Known scope limit, stated wherever the numbers are quoted: the + * fetch-duration mark allocates on the ENGINE thread (KeyEngine.launchFetch), so + * a caller-thread probe cannot see it — the JMH none-vs-noop timing deltas and the optional local + * `-prof gc` run cover the full cross-thread path. + */ +class TelemetryAllocationProbe { + @OptIn(ExperimentalStoreApi::class) + @Test + fun residentServe_callerThreadAllocationDelta_reported() { + val mx = ManagementFactory.getThreadMXBean() + if (mx !is com.sun.management.ThreadMXBean || !mx.isThreadAllocatedMemorySupported) { + println("TelemetryAllocationProbe: thread-allocation measurement unsupported on this JVM; skipping.") + return + } + + val allocatedMemoryWasEnabled = mx.isThreadAllocatedMemoryEnabled + try { + mx.isThreadAllocatedMemoryEnabled = true + runAbbaProbe(mx) + } finally { + mx.isThreadAllocatedMemoryEnabled = allocatedMemoryWasEnabled + } + } + + @OptIn(ExperimentalStoreApi::class) + private fun runAbbaProbe(mx: com.sun.management.ThreadMXBean) { + val warmupOps = 20_000 + val measuredOps = 100_000 + val key = BenchKey("alloc-probe") + val unsetSot = FakeSourceOfTruth() + val unsetStore = store { + fetcher { error("unreachable: LocalOnly") } + persistence(unsetSot) + } + val samples: AbbaSamples? = try { + val noopSot = FakeSourceOfTruth() + val noopStore = store { + fetcher { error("unreachable: LocalOnly") } + persistence(noopSot) + telemetry(NoopTelemetry) + } + try { + runBlocking { + unsetSot.write(key, "seed") + noopSot.write(key, "seed") + repeat(warmupOps) { unsetStore.get(key, Freshness.LocalOnly) } + repeat(warmupOps) { noopStore.get(key, Freshness.LocalOnly) } + + val tid = Thread.currentThread().id + val unsetFirst = measureSamplePerOp( + mx = mx, + tid = tid, + store = unsetStore, + key = key, + measuredOps = measuredOps, + label = "unset A", + ) ?: return@runBlocking null + val noopFirst = measureSamplePerOp( + mx = mx, + tid = tid, + store = noopStore, + key = key, + measuredOps = measuredOps, + label = "noop A", + ) ?: return@runBlocking null + val noopSecond = measureSamplePerOp( + mx = mx, + tid = tid, + store = noopStore, + key = key, + measuredOps = measuredOps, + label = "noop B", + ) ?: return@runBlocking null + val unsetSecond = measureSamplePerOp( + mx = mx, + tid = tid, + store = unsetStore, + key = key, + measuredOps = measuredOps, + label = "unset B", + ) ?: return@runBlocking null + AbbaSamples( + unsetFirst = unsetFirst, + unsetSecond = unsetSecond, + noopFirst = noopFirst, + noopSecond = noopSecond, + ) + } + } finally { + noopStore.close() + } + } finally { + unsetStore.close() + } + + if (samples == null) return + val unsetMean = (samples.unsetFirst + samples.unsetSecond) / 2 + val noopMean = (samples.noopFirst + samples.noopSecond) / 2 + println("TelemetryAllocationProbe (resident LocalOnly get, caller-thread bytes/op; warmed ABBA):") + println(" telemetry unset samples : ${samples.unsetFirst}, ${samples.unsetSecond} B/op") + println(" NoopTelemetry samples : ${samples.noopFirst}, ${samples.noopSecond} B/op") + println(" telemetry unset mean : $unsetMean B/op") + println(" NoopTelemetry mean : $noopMean B/op") + println(" aggregate delta (noop-unset): ${noopMean - unsetMean} B/op") + } + + private suspend fun measureSamplePerOp( + mx: com.sun.management.ThreadMXBean, + tid: Long, + store: Store, + key: BenchKey, + measuredOps: Int, + label: String, + ): Long? { + if (Thread.currentThread().id != tid) { + println( + "TelemetryAllocationProbe: allocation measurement inconclusive/unsupported: " + + "caller thread changed before $label.", + ) + return null + } + val before = mx.getThreadAllocatedBytes(tid) + if (before < 0) { + println( + "TelemetryAllocationProbe: allocation measurement inconclusive/unsupported: " + + "$label returned before=$before.", + ) + return null + } + repeat(measuredOps) { store.get(key, Freshness.LocalOnly) } + if (Thread.currentThread().id != tid) { + println( + "TelemetryAllocationProbe: allocation measurement inconclusive/unsupported: " + + "caller thread changed during $label.", + ) + return null + } + val after = mx.getThreadAllocatedBytes(tid) + if (after < 0 || after < before) { + println( + "TelemetryAllocationProbe: allocation measurement inconclusive/unsupported: " + + "$label returned before=$before, after=$after.", + ) + return null + } + return (after - before) / measuredOps + } + + private data class AbbaSamples( + val unsetFirst: Long, + val unsetSecond: Long, + val noopFirst: Long, + val noopSecond: Long, + ) +} diff --git a/store6-compose-demo/build.gradle.kts b/store6-compose-demo/build.gradle.kts new file mode 100644 index 000000000..76cc1d34d --- /dev/null +++ b/store6-compose-demo/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + alias(libs.plugins.kotlin.compose.compiler) + alias(libs.plugins.jetbrains.compose) +} + +kotlin { jvmToolchain(11) } + +val store6StabilityConfig = + rootProject.layout.projectDirectory.file("store6-compose/stability/store6-stability.conf") + +composeCompiler { + stabilityConfigurationFiles.add(store6StabilityConfig) + metricsDestination.set(layout.buildDirectory.dir("compose-metrics")) + reportsDestination.set(layout.buildDirectory.dir("compose-reports")) +} + +// The CI compose-stability gate reads the reports emitted below, so they must always describe +// the current sources and conf. Two Compose-plugin gaps otherwise break that: +// 1. stabilityConfigurationFiles is not registered as a task input, so a conf-only edit leaves +// compileKotlin UP-TO-DATE against stale settings — fixed by inputs.file below. +// 2. the reports are an undeclared side-effect output, so a build-cache hit (org.gradle.caching +// is on, and CI restores the cache) skips the compiler and leaves whatever report happens to +// be on disk — which would make the gate assert against a stale file. Opting these tiny +// compilations out of the cache keeps report-on-disk == inputs-on-disk; UP-TO-DATE still +// applies, and an up-to-date task's report was written by the last real execution with +// exactly these inputs. +tasks.withType().configureEach { + inputs.file(store6StabilityConfig).withPathSensitivity(PathSensitivity.RELATIVE) + outputs.cacheIf { false } +} + +dependencies { + implementation(projects.store6Compose) + implementation(projects.store6Testing) + implementation(compose.desktop.currentOs) + implementation(compose.material3) + testImplementation(kotlin("test")) + testImplementation(libs.kotlinx.coroutines.test) +} + +compose.desktop { + application { mainClass = "org.mobilenativefoundation.store6.composedemo.MainKt" } +} diff --git a/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoScreen.kt b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoScreen.kt new file mode 100644 index 000000000..cf1320001 --- /dev/null +++ b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoScreen.kt @@ -0,0 +1,106 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.composedemo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.mobilenativefoundation.store6.compose.collectAsState +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreResult + +@Composable +fun DemoScreen(store: Store, controls: DemoControls) { + val scope = rememberCoroutineScope() + val key = remember { UserKey("1") } + val result by store.collectAsState(key) + var lastData by remember { mutableStateOf?>(null) } + val current = result + val ui = deriveDemoUiState(current, lastData) + // Retain the last Data for the next composition. Assigning during composition would be a + // backwards write to state this composition already read; SideEffect defers it until the + // composition has successfully applied. + if (current is StoreResult.Data) { + SideEffect { lastData = current } + } + + MaterialTheme { + Column( + Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box(Modifier.fillMaxWidth().height(180.dp)) { + val data = ui.card + when { + data != null -> Card(Modifier.fillMaxSize()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(data.value.name, style = MaterialTheme.typography.headlineSmall) + Text("origin=${data.origin} refreshing=${data.refreshing}") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (ui.showStaleBadge) { + AssistChip(onClick = {}, label = { Text("STALE") }) + } + ui.errorBanner?.let { banner -> + Text(banner, color = MaterialTheme.colorScheme.error) + } + } + } + } + ui.showLoadingPlaceholder -> Text("Loading…") + ui.emptyError != null -> Text( + "Failed with no local value: ${ui.emptyError}", + color = MaterialTheme.colorScheme.error, + ) + else -> {} + } + if (ui.showSpinner) { + CircularProgressIndicator(Modifier.align(Alignment.TopEnd).size(28.dp)) + } + } + + val latency by controls.latencyMillis.collectAsState() + Text("Fetch latency: ${latency}ms") + Slider( + value = latency.toFloat(), + onValueChange = { controls.latencyMillis.value = it.toLong() }, + valueRange = 0f..5000f, + ) + val failing by controls.failFetches.collectAsState() + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Fail fetches") + Switch(checked = failing, onCheckedChange = { controls.failFetches.value = it }) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { scope.launch { store.invalidate(key) } }) { Text("Invalidate") } + Button(onClick = { scope.launch { store.clear(key) } }) { Text("Clear") } + } + } + } +} diff --git a/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoStore.kt b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoStore.kt new file mode 100644 index 000000000..2f4ee4b52 --- /dev/null +++ b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoStore.kt @@ -0,0 +1,53 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) + +package org.mobilenativefoundation.store6.composedemo + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.testing.FakeFetcher + +class UserKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} + +data class User(val id: String, val name: String) + +/** Live knobs the demo screen mutates while the store keeps fetching. */ +class DemoControls { + val latencyMillis = MutableStateFlow(1500L) + val failFetches = MutableStateFlow(false) +} + +/** + * Toggleable latency/failure around a store6-testing [FakeFetcher]: scripted results win, + * otherwise a deterministic versioned user is produced so every refetch visibly changes. + */ +class DemoFetcher( + private val controls: DemoControls, + val delegate: FakeFetcher = FakeFetcher(), +) : Fetcher { + private var version = 0 + + init { + delegate.onUnscripted = { key, _ -> + version += 1 + FetcherResult.Success(User(key.id, "User ${key.id} (v$version)")) + } + } + + override suspend fun fetch(key: UserKey, etag: String?): FetcherResult { + delay(controls.latencyMillis.value) + if (controls.failFetches.value) { + return FetcherResult.Error(IllegalStateException("Demo failure toggle is on")) + } + return delegate.fetch(key, etag) + } +} diff --git a/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiState.kt b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiState.kt new file mode 100644 index 000000000..718fcce79 --- /dev/null +++ b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiState.kt @@ -0,0 +1,44 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.composedemo + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreResult + +/** Everything the demo screen shows, derived purely from (current result, retained data). */ +data class DemoUiState( + /** Content card: the current Data, else the last Data retained across refresh/error. */ + val card: StoreResult.Data?, + /** Spinner over content: refreshing Data, or Loading while retained content exists. */ + val showSpinner: Boolean, + /** STALE badge on the card. */ + val showStaleBadge: Boolean, + /** Error banner over retained content; null when no error or no content to banner over. */ + val errorBanner: String?, + /** Error with no local value at all (full-surface error state). */ + val emptyError: StoreError?, + /** Initial Loading with nothing to show yet. */ + val showLoadingPlaceholder: Boolean, +) + +@Suppress("UNCHECKED_CAST") +fun deriveDemoUiState( + current: StoreResult, + previousData: StoreResult.Data?, +): DemoUiState { + val card = (current as? StoreResult.Data) ?: previousData + return DemoUiState( + card = card, + showSpinner = (current is StoreResult.Data<*> && current.refreshing) || + (current is StoreResult.Loading && card != null), + showStaleBadge = card?.isStale == true, + errorBanner = when { + current !is StoreResult.Error || card == null -> null + current.servedStale -> "Refresh failed — showing stale data" + else -> "Refresh failed" + }, + emptyError = if (current is StoreResult.Error && card == null) current.error else null, + showLoadingPlaceholder = current is StoreResult.Loading && card == null, + ) +} diff --git a/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt new file mode 100644 index 000000000..a87eba02d --- /dev/null +++ b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/Main.kt @@ -0,0 +1,19 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.composedemo + +import androidx.compose.ui.window.singleWindowApplication +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.store + +fun main() { + val controls = DemoControls() + val users = store { fetcher(DemoFetcher(controls)) } + // Process-scoped store on the bounded-registry engine: idle key engines + // are LRU-bounded (default maxIdleKeys = 128 — this demo touches a single key, far under + // the bound) and evicted only after quiescence. No explicit close() here by choice: the + // window exit tears the JVM down, and core's close carries a GC-fallback posture. + singleWindowApplication(title = "store6-compose demo") { + DemoScreen(users, controls) + } +} diff --git a/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/StabilityProbe.kt b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/StabilityProbe.kt new file mode 100644 index 000000000..c5838093e --- /dev/null +++ b/store6-compose-demo/src/main/kotlin/org/mobilenativefoundation/store6/composedemo/StabilityProbe.kt @@ -0,0 +1,48 @@ +package org.mobilenativefoundation.store6.composedemo + +import androidx.compose.runtime.Composable +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult + +/** + * Consumed by the CI compose-stability gate. Strict tier: concrete core classes — the shipped + * stability conf must make these stable (store6-core is not compiled with the compose compiler, + * so without the conf every core type is external-unstable). Iface tier: interface/abstract-typed + * parameters, which the gate currently requires to render stable as well. + */ +private fun consume(value: Any?) { + check(value !== StabilityProbeMarker) +} + +private object StabilityProbeMarker + +@Composable fun ProbeStrictData(value: StoreResult.Data) = consume(value) + +@Composable fun ProbeStrictLoading(value: StoreResult.Loading) = consume(value) + +@Composable fun ProbeStrictRevalidated(value: StoreResult.Revalidated) = consume(value) + +@Composable fun ProbeStrictError(value: StoreResult.Error) = consume(value) + +@Composable fun ProbeStrictMaxAge(value: Freshness.MaxAge) = consume(value) + +@Composable fun ProbeStrictOrigin(value: Origin) = consume(value) + +@Composable fun ProbeStrictNamespace(value: StoreNamespace) = consume(value) + +@Composable fun ProbeStrictFetchError(value: StoreError.Fetch) = consume(value) + +@Composable fun ProbeIfaceStoreResult(value: StoreResult) = consume(value) + +@Composable fun ProbeIfaceFreshness(value: Freshness) = consume(value) + +@Composable fun ProbeIfaceStoreKey(value: StoreKey) = consume(value) + +@Composable fun ProbeIfaceStoreMeta(value: StoreMeta) = consume(value) + +@Composable fun ProbeIfaceStoreError(value: StoreError) = consume(value) diff --git a/store6-compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoFetcherTest.kt b/store6-compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoFetcherTest.kt new file mode 100644 index 000000000..2d5bfb322 --- /dev/null +++ b/store6-compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoFetcherTest.kt @@ -0,0 +1,36 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) + +package org.mobilenativefoundation.store6.composedemo + +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class DemoFetcherTest { + @Test + fun failureToggleProducesFetcherError() = runTest { + val controls = DemoControls().apply { failFetches.value = true } + val fetcher = DemoFetcher(controls) + assertIs(fetcher.fetch(UserKey("1"), etag = null)) + } + + @Test + fun unscriptedFetchesProduceVersionedUsersAndScriptedResultsWin() = runTest { + val controls = DemoControls().apply { latencyMillis.value = 3000L } + val fetcher = DemoFetcher(controls) + val key = UserKey("1") + val first = fetcher.fetch(key, etag = null) + assertIs>(first) + assertEquals("User 1 (v1)", first.value.name) + assertEquals(3000L * 1, currentTime) // virtual latency honored + fetcher.delegate.enqueue(key, FetcherResult.Success(User("1", "Scripted"))) + val second = fetcher.fetch(key, etag = null) + assertIs>(second) + assertEquals("Scripted", second.value.name) + } +} diff --git a/store6-compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiStateTest.kt b/store6-compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiStateTest.kt new file mode 100644 index 000000000..5afdae184 --- /dev/null +++ b/store6-compose-demo/src/test/kotlin/org/mobilenativefoundation/store6/composedemo/DemoUiStateTest.kt @@ -0,0 +1,78 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.composedemo + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.testing.TestStoreResults +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class DemoUiStateTest { + private fun data(name: String, stale: Boolean = false, refreshing: Boolean = false): StoreResult.Data = + TestStoreResults.data( + value = User("1", name), origin = Origin.FETCHER, isStale = stale, refreshing = refreshing, + ) + + @Test fun initialLoadingShowsPlaceholderOnly() { + val ui = deriveDemoUiState(TestStoreResults.loading(), previousData = null) + assertTrue(ui.showLoadingPlaceholder) + assertNull(ui.card) + assertFalse(ui.showSpinner) + assertNull(ui.errorBanner) + } + + @Test fun refreshingDataShowsSpinnerOverContent() { + val ui = deriveDemoUiState(data("alice", refreshing = true), previousData = null) + assertNotNull(ui.card) + assertTrue(ui.showSpinner) + assertFalse(ui.showLoadingPlaceholder) + } + + @Test fun loadingAfterDataRetainsCardAndSpins() { + val prev = data("alice") + val ui = deriveDemoUiState(TestStoreResults.loading(), previousData = prev) + assertSame(prev, ui.card) + assertTrue(ui.showSpinner) + assertFalse(ui.showLoadingPlaceholder) + } + + @Test fun staleCardShowsBadge() { + val ui = deriveDemoUiState(data("alice", stale = true), previousData = null) + assertTrue(ui.showStaleBadge) + } + + @Test fun servedStaleErrorBannersOverRetainedCard() { + val prev = data("alice", stale = true) + val error = TestStoreResults.error(TestStoreResults.fetchError("boom"), servedStale = true) + val ui = deriveDemoUiState(error, previousData = prev) + assertSame(prev, ui.card) + assertEquals("Refresh failed — showing stale data", ui.errorBanner) + assertTrue(ui.showStaleBadge) + assertNull(ui.emptyError) + } + + @Test fun freshErrorOverRetainedCardBannersWithoutTheStaleWording() { + val prev = data("alice") + val error = TestStoreResults.error(TestStoreResults.fetchError("boom"), servedStale = false) + val ui = deriveDemoUiState(error, previousData = prev) + assertSame(prev, ui.card) + assertEquals("Refresh failed", ui.errorBanner) + assertFalse(ui.showStaleBadge) + assertNull(ui.emptyError) + } + + @Test fun errorWithNoLocalValueSurfacesEmptyError() { + val error = TestStoreResults.error(TestStoreResults.fetchError("boom"), servedStale = false) + val ui = deriveDemoUiState(error, previousData = null) + assertNull(ui.card) + assertNull(ui.errorBanner) + assertNotNull(ui.emptyError) + } +} diff --git a/store6-compose/README.md b/store6-compose/README.md new file mode 100644 index 000000000..812c83b0f --- /dev/null +++ b/store6-compose/README.md @@ -0,0 +1,61 @@ +# store6-compose + +Compose Multiplatform integration for Store v6. Everything here is `@ExperimentalStoreApi`. +The seam it consumes is a freeze candidate, not frozen — see [STABILITY.md](../STABILITY.md). + +## Entry points + +- `Store.collectAsState(key, freshness)` — `State>`, starts at `Loading`, + restarts only on structural identity change (namespace/canonicalId/freshness), all targets. +- `Flow>.collectAsStoreState(initial)` — the flow-level variant. +- `Store.collectAsStateWithLifecycle(...)` / `collectAsStoreStateWithLifecycle(...)` — + lifecycle-gated via `repeatOnLifecycle`, on all targets. These need a `LifecycleOwner`; on + targets with no UI host that populates `LocalLifecycleOwner`, pass one explicitly. +- `skipEqualData()` / `storeResultMutationPolicy()` — structural skipping for stateIn/ViewModel + flows and custom state holders. + +## Recomposition discipline + +`StoreResult` types deliberately have identity equality. This module skips recomposition by +structural comparison of `Data`'s value/origin/isStale/refreshing — `age` is excluded (it +advances every emission). Results are never merged across kinds. That mirrors the engine's +`conflateLatestData` rule (same-kind latest-wins; never merged across +kinds): "Revalidated is a lifecycle signal: `conflateLatestData` never conflates it away in +favor of another kind; for a blocked collector a newer `Revalidated` supersedes an older queued +one, so the kind itself is never lost." This module is stricter still — `Loading`/`Revalidated`/ +`Error` always pass; only structurally-equal consecutive `Data` frames are dropped. Event-shaped +consumption of `Revalidated`/`Error` should collect the Flow, not a State. + +## Stability configuration for consumers + +Strong skipping (default since Kotlin 2.0.20) compares unstable parameters by instance; this +module's state holders keep instances stable across equal frames, so skipping works out of the +box. To make store types compare as stable values instead — which is what lets the compiler skip +on equal *content* rather than equal *instance* — add the shipped snippet +(`stability/store6-stability.conf`, reproduced below) to your app module: + + composeCompiler { + stabilityConfigurationFiles.add( + layout.projectDirectory.file("store6-stability.conf"), + ) + } + + // store6-stability.conf (mirror of the shipped file) + org.mobilenativefoundation.store6.core.* + org.mobilenativefoundation.store6.core.seam.* + +CI verifies this exact snippet against a tiered probe of core public types on every PR. With the +snippet applied, every probed core type — including the interface-typed ones (`StoreResult`, +`Freshness`, `StoreKey`, `StoreMeta`, `StoreError`) and the generic `StoreResult.Data` — +resolves as **stable**; without it, they resolve as `unstable` and the CI gate fails. + +Note that `composeCompiler.stabilityConfigurationFiles` is not registered as a Gradle task input +by the Compose compiler plugin, and the emitted stability reports are an undeclared output. This +module's build scripts compensate (`inputs.file(...)` plus opting the demo compilations out of +the build cache) so that editing the conf always re-emits a matching report; consumers relying on +their own report-based checks should do the same. + +## Demo + +`./gradlew :store6-compose-demo:run` — refreshing spinner-over-content, STALE badge, and +error-with-stale-data against a fake fetcher with toggleable latency and failure. diff --git a/store6-compose/api/android/store6-compose.api b/store6-compose/api/android/store6-compose.api new file mode 100644 index 000000000..304aa0b10 --- /dev/null +++ b/store6-compose/api/android/store6-compose.api @@ -0,0 +1,17 @@ +public final class org/mobilenativefoundation/store6/compose/CollectAsStateKt { + public static final fun collectAsState (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; + public static final fun collectAsStoreState (Lkotlinx/coroutines/flow/Flow;Lorg/mobilenativefoundation/store6/core/StoreResult;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; +} + +public final class org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycleKt { + public static final fun collectAsStateWithLifecycle (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$State;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; + public static final fun collectAsStoreStateWithLifecycle (Lkotlinx/coroutines/flow/Flow;Lorg/mobilenativefoundation/store6/core/StoreResult;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$State;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; +} + +public final class org/mobilenativefoundation/store6/compose/StoreResultEquivalenceKt { + public static final fun skipEqualData (Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun skipEqualData$default (Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; + public static final fun storeResultMutationPolicy (Lkotlin/jvm/functions/Function2;)Landroidx/compose/runtime/SnapshotMutationPolicy; + public static synthetic fun storeResultMutationPolicy$default (Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Landroidx/compose/runtime/SnapshotMutationPolicy; +} + diff --git a/store6-compose/api/jvm/store6-compose.api b/store6-compose/api/jvm/store6-compose.api new file mode 100644 index 000000000..304aa0b10 --- /dev/null +++ b/store6-compose/api/jvm/store6-compose.api @@ -0,0 +1,17 @@ +public final class org/mobilenativefoundation/store6/compose/CollectAsStateKt { + public static final fun collectAsState (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; + public static final fun collectAsStoreState (Lkotlinx/coroutines/flow/Flow;Lorg/mobilenativefoundation/store6/core/StoreResult;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; +} + +public final class org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycleKt { + public static final fun collectAsStateWithLifecycle (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$State;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; + public static final fun collectAsStoreStateWithLifecycle (Lkotlinx/coroutines/flow/Flow;Lorg/mobilenativefoundation/store6/core/StoreResult;Landroidx/lifecycle/LifecycleOwner;Landroidx/lifecycle/Lifecycle$State;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; +} + +public final class org/mobilenativefoundation/store6/compose/StoreResultEquivalenceKt { + public static final fun skipEqualData (Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun skipEqualData$default (Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; + public static final fun storeResultMutationPolicy (Lkotlin/jvm/functions/Function2;)Landroidx/compose/runtime/SnapshotMutationPolicy; + public static synthetic fun storeResultMutationPolicy$default (Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Landroidx/compose/runtime/SnapshotMutationPolicy; +} + diff --git a/store6-compose/api/store6-compose.klib.api b/store6-compose/api/store6-compose.klib.api new file mode 100644 index 000000000..83587363a --- /dev/null +++ b/store6-compose/api/store6-compose.klib.api @@ -0,0 +1,14 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, linuxX64, macosArm64, mingwX64, tvosArm64, wasmJs, watchosArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final fun <#A: kotlin/Any?> (kotlinx.coroutines.flow/Flow>).org.mobilenativefoundation.store6.compose/collectAsStoreState(org.mobilenativefoundation.store6.core/StoreResult<#A>?, kotlin/Function2<#A, #A, kotlin/Boolean>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.runtime/State> // org.mobilenativefoundation.store6.compose/collectAsStoreState|collectAsStoreState@kotlinx.coroutines.flow.Flow>(org.mobilenativefoundation.store6.core.StoreResult<0:0>?;kotlin.Function2<0:0,0:0,kotlin.Boolean>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§}[0] +final fun <#A: kotlin/Any?> (kotlinx.coroutines.flow/Flow>).org.mobilenativefoundation.store6.compose/collectAsStoreStateWithLifecycle(org.mobilenativefoundation.store6.core/StoreResult<#A>?, androidx.lifecycle/LifecycleOwner?, androidx.lifecycle/Lifecycle.State?, kotlin/Function2<#A, #A, kotlin/Boolean>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.runtime/State> // org.mobilenativefoundation.store6.compose/collectAsStoreStateWithLifecycle|collectAsStoreStateWithLifecycle@kotlinx.coroutines.flow.Flow>(org.mobilenativefoundation.store6.core.StoreResult<0:0>?;androidx.lifecycle.LifecycleOwner?;androidx.lifecycle.Lifecycle.State?;kotlin.Function2<0:0,0:0,kotlin.Boolean>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§}[0] +final fun <#A: kotlin/Any?> (kotlinx.coroutines.flow/Flow>).org.mobilenativefoundation.store6.compose/skipEqualData(kotlin/Function2<#A, #A, kotlin/Boolean> = ...): kotlinx.coroutines.flow/Flow> // org.mobilenativefoundation.store6.compose/skipEqualData|skipEqualData@kotlinx.coroutines.flow.Flow>(kotlin.Function2<0:0,0:0,kotlin.Boolean>){0§}[0] +final fun <#A: kotlin/Any?> org.mobilenativefoundation.store6.compose/storeResultMutationPolicy(kotlin/Function2<#A, #A, kotlin/Boolean> = ...): androidx.compose.runtime/SnapshotMutationPolicy> // org.mobilenativefoundation.store6.compose/storeResultMutationPolicy|storeResultMutationPolicy(kotlin.Function2<0:0,0:0,kotlin.Boolean>){0§}[0] +final fun <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> (org.mobilenativefoundation.store6.core/Store<#A, #B>).org.mobilenativefoundation.store6.compose/collectAsState(#A, org.mobilenativefoundation.store6.core/Freshness?, kotlin/Function2<#B, #B, kotlin/Boolean>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.runtime/State> // org.mobilenativefoundation.store6.compose/collectAsState|collectAsState@org.mobilenativefoundation.store6.core.Store<0:0,0:1>(0:0;org.mobilenativefoundation.store6.core.Freshness?;kotlin.Function2<0:1,0:1,kotlin.Boolean>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§;1§}[0] +final fun <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> (org.mobilenativefoundation.store6.core/Store<#A, #B>).org.mobilenativefoundation.store6.compose/collectAsStateWithLifecycle(#A, org.mobilenativefoundation.store6.core/Freshness?, androidx.lifecycle/LifecycleOwner?, androidx.lifecycle/Lifecycle.State?, kotlin/Function2<#B, #B, kotlin/Boolean>?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int): androidx.compose.runtime/State> // org.mobilenativefoundation.store6.compose/collectAsStateWithLifecycle|collectAsStateWithLifecycle@org.mobilenativefoundation.store6.core.Store<0:0,0:1>(0:0;org.mobilenativefoundation.store6.core.Freshness?;androidx.lifecycle.LifecycleOwner?;androidx.lifecycle.Lifecycle.State?;kotlin.Function2<0:1,0:1,kotlin.Boolean>?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){0§;1§}[0] diff --git a/store6-compose/build.gradle.kts b/store6-compose/build.gradle.kts new file mode 100644 index 000000000..7c8359c6c --- /dev/null +++ b/store6-compose/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + id("org.mobilenativefoundation.store.store6.multiplatform") + alias(libs.plugins.kotlin.compose.compiler) +} + +val store6StabilityConfig = layout.projectDirectory.file("stability/store6-stability.conf") + +composeCompiler { + // Dogfoods the shipped consumer snippet: core types are stable inside this module's own + // composables. The conf file lands in this same task (T1) so this wiring never dangles. + stabilityConfigurationFiles.add(store6StabilityConfig) +} + +// The Compose compiler plugin does not register stabilityConfigurationFiles as a task input, so +// a conf-only edit would otherwise leave these compilations UP-TO-DATE against stale settings. +tasks.withType>().configureEach { + inputs.file(store6StabilityConfig).withPathSensitivity(PathSensitivity.RELATIVE) +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + api(projects.store6Core) + // PIN (preflight): CMP 1.8.2 is the pinned Compose Multiplatform runtime line. + api(libs.jetbrains.compose.runtime) + // lifecycle-runtime-compose 2.9.1 publishes every canonical Store6 target, + // including linuxX64, mingwX64, tvosArm64 and watchosArm64 (verified against the + // published Gradle module metadata), so the lifecycle-gated entry points live in + // commonMain and ship on all 12 targets rather than a restricted tier. + api(libs.jetbrains.lifecycle.runtime.compose) + } + } + val commonTest by getting { + dependencies { + implementation(projects.store6Testing) + implementation(libs.kotlinx.coroutines.test) + } + } + } +} + +android { + namespace = "org.mobilenativefoundation.store6.compose" + + // The shared commonTest suites drive a real Composition on every target, and the Compose + // runtime traces composition disposal through android.os.Trace. Under Android local unit + // tests that class is an unimplemented android.jar stub, so it throws instead of no-opping. + // Returning stub defaults keeps the Android variant running the same suites as every other + // target; no assertion in this module depends on an android.jar return value. + testOptions { + unitTests.isReturnDefaultValues = true + } +} diff --git a/store6-compose/gradle.properties b/store6-compose/gradle.properties new file mode 100644 index 000000000..79da1c9a2 --- /dev/null +++ b/store6-compose/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=store6-compose +POM_ARTIFACT_ID=store6-compose diff --git a/rx2/src/main/AndroidManifest.xml b/store6-compose/src/androidMain/AndroidManifest.xml similarity index 100% rename from rx2/src/main/AndroidManifest.xml rename to store6-compose/src/androidMain/AndroidManifest.xml diff --git a/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt b/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt new file mode 100644 index 000000000..de42634ae --- /dev/null +++ b/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsState.kt @@ -0,0 +1,79 @@ +package org.mobilenativefoundation.store6.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.StoreResults + +/** + * Collects [Store.stream] for [key] as compose [State], starting at [StoreResult.Loading]. + * Collection restarts only when the store instance or the structural stream identity changes: + * `(namespace.value, canonicalId(), freshness)` with [Freshness.MaxAge] compared by its duration — + * a new-but-equal key or policy instance never restarts collection. Structurally equal consecutive + * [StoreResult.Data] frames do not invalidate readers (see [storeResultMutationPolicy]; age + * excluded). [valueEquivalence] is captured at first composition for a given restart identity. + * Collection is scoped to the composition; for lifecycle-gated collection use + * `collectAsStateWithLifecycle` on the CMP lifecycle tier. + * + * Closed-store behavior: calling this on a closed store fails the composition — [Store.stream] + * throws [IllegalStateException] inside the launched effect (the stream is guarded both at call + * and at collection start); a collection cancelled by [Store.close] ends as coroutine + * cancellation. The close message is engine-internal diagnostic text, not ABI. + */ +@ExperimentalStoreApi +@Composable +public fun Store.collectAsState( + key: K, + freshness: Freshness = Freshness.CachedOrFetch, + valueEquivalence: (V, V) -> Boolean = { a, b -> a == b }, +): State> { + val restartKey = streamRestartKey(key, freshness) + val state = remember(this, restartKey) { + mutableStateOf>(StoreResults.loading(), storeResultMutationPolicy(valueEquivalence)) + } + LaunchedEffect(this, restartKey) { + stream(key, freshness).collect { state.value = it } + } + return state +} + +/** + * Collects a flow of store results as compose [State] beginning at [initial] (default + * [StoreResults.loading]), holding it with [storeResultMutationPolicy] so structurally equal + * consecutive [StoreResult.Data] frames skip recomposition while lifecycle results always land. + * Collection restarts when the flow instance changes and is scoped to the composition. + */ +@ExperimentalStoreApi +@Composable +public fun Flow>.collectAsStoreState( + initial: StoreResult = StoreResults.loading(), + valueEquivalence: (V, V) -> Boolean = { a, b -> a == b }, +): State> { + val state = remember(this) { + mutableStateOf(initial, storeResultMutationPolicy(valueEquivalence)) + } + LaunchedEffect(this) { collect { state.value = it } } + return state +} + +internal fun streamRestartKey(key: StoreKey, freshness: Freshness): Any = + Triple(key.namespace.value, key.canonicalId(), freshnessToken(freshness)) + +private fun freshnessToken(freshness: Freshness): Any = + when (freshness) { + is Freshness.MaxAge -> "MaxAge:${freshness.notOlderThan}" + // GUARD: every other `Freshness` variant is a `data object`, so instance identity IS a + // stable token. MaxAge is the sole plain class with identity equality and must be + // normalized by value. If core ever adds another non-singleton Freshness, add a branch + // for it here — otherwise a new-but-equal instance silently restarts collection on every + // recomposition, which is exactly the footgun the MaxAge branch exists to prevent. + else -> freshness + } diff --git a/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycle.kt b/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycle.kt new file mode 100644 index 000000000..2e78f767d --- /dev/null +++ b/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycle.kt @@ -0,0 +1,71 @@ +package org.mobilenativefoundation.store6.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.StoreResults + +/** + * Lifecycle-gated [collectAsState]: collection runs only at or above [minActiveState] via + * [repeatOnLifecycle], retains the last result while stopped, and re-collects [Store.stream] + * from scratch on re-entry (the engine re-emits the current snapshot first, so the State + * catches up without a Loading reset). Under the bounded key registry a paused collection + * releases its engine refcount; a quiescent idle engine may be evicted (LRU, default + * `maxIdleKeys` 128) and is transparently rebuilt on re-entry — no API-visible difference. + * Requires a populated [LocalLifecycleOwner] (any CMP UI host or Android component provides + * one) unless [lifecycleOwner] is passed explicitly. On targets with no UI host that populates + * it — linuxX64, mingwX64 and the non-simulator Apple targets, in practice — pass + * [lifecycleOwner] explicitly or use [collectAsState]. + */ +@ExperimentalStoreApi +@Composable +public fun Store.collectAsStateWithLifecycle( + key: K, + freshness: Freshness = Freshness.CachedOrFetch, + lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, + valueEquivalence: (V, V) -> Boolean = { a, b -> a == b }, +): State> { + val restartKey = streamRestartKey(key, freshness) + val state = remember(this, restartKey) { + mutableStateOf>(StoreResults.loading(), storeResultMutationPolicy(valueEquivalence)) + } + LaunchedEffect(this, restartKey, lifecycleOwner, minActiveState) { + lifecycleOwner.repeatOnLifecycle(minActiveState) { + stream(key, freshness).collect { state.value = it } + } + } + return state +} + +/** Lifecycle-gated [collectAsStoreState]; see [collectAsStateWithLifecycle]. */ +@ExperimentalStoreApi +@Composable +public fun Flow>.collectAsStoreStateWithLifecycle( + initial: StoreResult = StoreResults.loading(), + lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, + minActiveState: Lifecycle.State = Lifecycle.State.STARTED, + valueEquivalence: (V, V) -> Boolean = { a, b -> a == b }, +): State> { + val state = remember(this) { + mutableStateOf(initial, storeResultMutationPolicy(valueEquivalence)) + } + LaunchedEffect(this, lifecycleOwner, minActiveState) { + lifecycleOwner.repeatOnLifecycle(minActiveState) { + collect { state.value = it } + } + } + return state +} diff --git a/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/StoreResultEquivalence.kt b/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/StoreResultEquivalence.kt new file mode 100644 index 000000000..ab3fcf59c --- /dev/null +++ b/store6-compose/src/commonMain/kotlin/org/mobilenativefoundation/store6/compose/StoreResultEquivalence.kt @@ -0,0 +1,65 @@ +package org.mobilenativefoundation.store6.compose + +import androidx.compose.runtime.SnapshotMutationPolicy +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreResult + +/** + * Structural equivalence for snapshot state holding [StoreResult]: two [StoreResult.Data] are + * equivalent iff origin, isStale, and refreshing match and [valueEquivalence] accepts the values. + * [StoreResult.Data.age] is deliberately excluded: it advances on every emission and would defeat + * recomposition skipping; derive live age from a clock when displaying it. Results of different + * kinds are never equivalent, and lifecycle results (Loading, Revalidated, Error) are never + * merged except as identical instances — a State is a conflated container, so event-shaped + * consumption of Revalidated/Error must collect the Flow. This exists because StoreResult types + * have identity equality by design (no equals override). + */ +@ExperimentalStoreApi +public fun storeResultMutationPolicy( + valueEquivalence: (V, V) -> Boolean = { a, b -> a == b }, +): SnapshotMutationPolicy> = object : SnapshotMutationPolicy> { + override fun equivalent(a: StoreResult, b: StoreResult): Boolean = + structurallyEquivalent(a, b, valueEquivalence) +} + +/** + * Drops structurally-equal consecutive [StoreResult.Data] frames; every lifecycle result + * (Loading, Revalidated, Error) always passes, and no result is ever dropped in favor of a + * different kind. This mirrors the engine's `conflateLatestData` discipline — same-kind + * latest-wins, never merged across kinds — whose public contract reads: "Revalidated is a + * lifecycle signal: `conflateLatestData` never conflates it away in favor of another kind; for a + * blocked collector a newer `Revalidated` supersedes an older queued one, so the kind itself is + * never lost." This operator is stricter still: it never supersedes lifecycle results at all — + * only exact structural Data duplicates are dropped. Age is excluded from the comparison (see + * [storeResultMutationPolicy]). This is a store6-compose convenience for stateIn/ViewModel + * consumers; `conflateLatestData` governs store6-core's own stream, not this module. + */ +@ExperimentalStoreApi +public fun Flow>.skipEqualData( + valueEquivalence: (V, V) -> Boolean = { a, b -> a == b }, +): Flow> = flow { + var previous: StoreResult? = null + collect { result -> + val last = previous + previous = result + val duplicate = last is StoreResult.Data<*> && result is StoreResult.Data<*> && + structurallyEquivalent(last, result, valueEquivalence) + if (!duplicate) emit(result) + } +} + +@Suppress("UNCHECKED_CAST") +internal fun structurallyEquivalent( + a: StoreResult, + b: StoreResult, + valueEquivalence: (V, V) -> Boolean, +): Boolean { + if (a === b) return true + if (a !is StoreResult.Data<*> || b !is StoreResult.Data<*>) return false + return a.origin == b.origin && + a.isStale == b.isStale && + a.refreshing == b.refreshing && + valueEquivalence(a.value as V, b.value as V) +} diff --git a/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt new file mode 100644 index 000000000..4d318a007 --- /dev/null +++ b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ClosedStoreBehaviorTest.kt @@ -0,0 +1,68 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.compose + +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.testing.FakeStore +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.test.runTest as coroutineRunTest + +/** + * Type-only characterization of the closed-store behavior the composables inherit, asserted at the + * exact `Store.stream` seam they call. The close message is engine-internal diagnostic text, not + * ABI, so no message text is asserted here. + */ +class ClosedStoreBehaviorTest { + private class TestKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id + } + + @Test + fun streamOnClosedStoreThrowsIllegalStateException() { + val store = FakeStore() + store.close() + assertFailsWith { store.stream(TestKey("1")) } + } + + /** + * The stream is guarded twice — at call and again at collection start. This is the second + * guard: the Flow is obtained while the store is open, so only the collection-start check can + * reject it. That is the path a composable takes when the store closes between composition + * and the LaunchedEffect body running. + */ + @Test + fun streamObtainedBeforeCloseThrowsAtCollectionStart(): TestResult = runTest { + val store = FakeStore() + val key = TestKey("1") + store.setValue(key, "v1") + val stream = store.stream(key) + store.close() + assertFailsWith { stream.collect {} } + } + + @Test + fun closeDuringCollectionEndsAsCancellation(): TestResult = runTest { + val store = FakeStore() + val key = TestKey("1") + store.setValue(key, "v1") + val collector = launch { store.stream(key).collect {} } + testScheduler.runCurrent() // collection is live + store.close() + collector.join() + assertTrue(collector.isCancelled) + } +} + +// One file-private 25s runTest shadow, no nested wall-clock waits. +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateTest.kt b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateTest.kt new file mode 100644 index 000000000..923736a29 --- /dev/null +++ b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateTest.kt @@ -0,0 +1,167 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.compose + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.TestResult +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.testing.FakeStore +import org.mobilenativefoundation.store6.testing.TestStoreResults +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.time.Duration.Companion.milliseconds + +class CollectAsStateTest { + private class TestKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id + } + + @Test + fun firstCompositionReportsLoadingThenScriptedData(): TestResult { + val store = FakeStore() + val key = TestKey("1") + store.enqueueFetchValue(key, "alice") + val observed = mutableListOf>() + lateinit var state: State> + return runComposeTest(content = { + state = store.collectAsState(key) + observed += state.value + }) { host -> + assertIs(observed.first()) + host.awaitUntil { state.value is StoreResult.Data<*> } + assertEquals("alice", (state.value as StoreResult.Data).value) + } + } + + @Test + fun structurallyEqualDataFramesDoNotRecompose(): TestResult { + val flow = MutableSharedFlow>(replay = 1) + var recompositions = 0 + // What the composition actually READ, as opposed to what the State already holds: a + // State write lands a frame before the recomposition that observes it, so counting + // recompositions against the raw State would sample a stale baseline. + var rendered: StoreResult? = null + fun data(value: String, ageMillis: Long) = TestStoreResults.data( + value = value, origin = Origin.FETCHER, age = ageMillis.milliseconds, + isStale = false, refreshing = false, + ) + return runComposeTest(content = { + val state = flow.collectAsStoreState() + recompositions += 1 + rendered = state.value + }) { host -> + flow.emit(data("alice", 0)) + host.awaitUntil { (rendered as? StoreResult.Data)?.value == "alice" } + host.advanceFrame() // drain any frame still in flight before sampling + val baseline = recompositions + flow.emit(data("alice", 40)) // new instance, equal sans age + host.advanceFrame() + host.advanceFrame() + assertEquals(baseline, recompositions) + flow.emit(data("bob", 80)) + host.awaitUntil { (rendered as? StoreResult.Data)?.value == "bob" } + assertEquals(baseline + 1, recompositions) + } + } + + @Test + fun lifecycleFramesAlwaysRecompose(): TestResult { + val flow = MutableSharedFlow>(replay = 1) + var recompositions = 0 + var rendered: StoreResult? = null + return runComposeTest(content = { + val state = flow.collectAsStoreState() + recompositions += 1 + rendered = state.value + }) { host -> + flow.emit(TestStoreResults.error(TestStoreResults.fetchError("boom"), servedStale = true)) + host.awaitUntil { rendered is StoreResult.Error } + host.advanceFrame() // drain any frame still in flight before sampling + val afterFirst = recompositions + val firstRendered = rendered + // A distinct but structurally identical Error instance must still land: lifecycle + // results are never merged, only Data duplicates are dropped. + flow.emit(TestStoreResults.error(TestStoreResults.fetchError("boom"), servedStale = true)) + host.awaitUntil { rendered !== firstRendered } + assertEquals(afterFirst + 1, recompositions) + } + } + + @Test + fun equalKeyInstanceDoesNotRestartAndNewKeyDoes(): TestResult { + val store = FakeStore() + store.setValue(TestKey("1"), "alice") + store.setValue(TestKey("2"), "bob") + val currentKey = mutableStateOf(TestKey("1")) + lateinit var state: State> + return runComposeTest(content = { + val key = currentKey.value + state = store.collectAsState(key) + }) { host -> + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "alice" } + val interactionsAfterFirst = store.interactions.size + currentKey.value = TestKey("1") // new instance, same identity + host.advanceFrame() + host.advanceFrame() + assertEquals(interactionsAfterFirst, store.interactions.size) + assertEquals("alice", (state.value as StoreResult.Data).value) + currentKey.value = TestKey("2") + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "bob" } + } + } + + /** + * [Freshness.MaxAge] is the one Freshness subtype that is a plain class with identity + * equality, so the restart key normalizes it by duration. Without that normalization the + * natural call shape — allocating `MaxAge(...)` inline in the composable — would restart + * collection on every recomposition. + */ + @Test + fun equalMaxAgeInstanceDoesNotRestartAndDifferentMaxAgeDoes(): TestResult { + val store = FakeStore() + val key = TestKey("1") + store.setValue(key, "alice") + val freshness = mutableStateOf(Freshness.MaxAge(30.milliseconds)) + lateinit var state: State> + return runComposeTest(content = { + state = store.collectAsState(key, freshness.value) + }) { host -> + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "alice" } + val afterFirst = store.interactions.size + freshness.value = Freshness.MaxAge(30.milliseconds) // new instance, equal duration + host.advanceFrame() + host.advanceFrame() + assertEquals(afterFirst, store.interactions.size) + freshness.value = Freshness.MaxAge(90.milliseconds) // different duration + host.awaitUntil { store.interactions.size > afterFirst } + assertEquals(afterFirst + 1, store.interactions.size) + } + } + + @Test + fun initialResultIsRenderedBeforeAnyEmission(): TestResult { + val flow = MutableSharedFlow>(replay = 1) + val seeded = TestStoreResults.data( + value = "seed", origin = Origin.MEMORY, isStale = true, refreshing = false, + ) + var rendered: StoreResult? = null + return runComposeTest(content = { + rendered = flow.collectAsStoreState(initial = seeded).value + }) { host -> + assertSame(seeded, rendered) + flow.emit(TestStoreResults.data(value = "fresh", origin = Origin.FETCHER)) + host.awaitUntil { (rendered as? StoreResult.Data)?.value == "fresh" } + } + } +} diff --git a/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycleTest.kt b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycleTest.kt new file mode 100644 index 000000000..af87b67bc --- /dev/null +++ b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/CollectAsStateWithLifecycleTest.kt @@ -0,0 +1,123 @@ +@file:OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) + +package org.mobilenativefoundation.store6.compose + +import androidx.compose.runtime.State +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.testing.FakeStore +import org.mobilenativefoundation.store6.testing.TestStoreResults +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class CollectAsStateWithLifecycleTest { + private class TestKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id + } + + private class TestOwner : LifecycleOwner { + private val registry = LifecycleRegistry.createUnsafe(this) + + override val lifecycle: Lifecycle get() = registry + + fun moveTo(state: Lifecycle.State) { + registry.currentState = state + } + } + + @BeforeTest fun setUp() = Dispatchers.setMain(StandardTestDispatcher()) + + @AfterTest fun tearDown() = Dispatchers.resetMain() + + @Test + fun collectionPausesBelowMinActiveStateAndCatchesUpOnReentry(): TestResult { + val store = FakeStore() + val key = TestKey("1") + store.setValue(key, "v1") + val owner = TestOwner().apply { moveTo(Lifecycle.State.STARTED) } + lateinit var state: State> + return runComposeTest(content = { + state = store.collectAsStateWithLifecycle(key, lifecycleOwner = owner) + }) { host -> + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "v1" } + owner.moveTo(Lifecycle.State.CREATED) + host.advanceFrame() + store.setValue(key, "v2") + host.advanceFrame() + host.advanceFrame() + assertEquals("v1", (state.value as StoreResult.Data).value) // retained, not reset + owner.moveTo(Lifecycle.State.STARTED) + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "v2" } + } + } + + /** + * The documented contract is that re-entry retains the last result rather than resetting to + * Loading. A `Store.stream` re-emits its current snapshot immediately, which would paper over + * a reset, so this asserts against a replayless flow: on re-entry nothing arrives, and the + * only way the state can be anything other than the retained value is an actual reset. + */ + @Test + fun reentryRetainsTheLastResultInsteadOfResettingToLoading(): TestResult { + val flow = MutableSharedFlow>() // replay = 0: re-entry re-delivers nothing + val owner = TestOwner().apply { moveTo(Lifecycle.State.STARTED) } + lateinit var state: State> + return runComposeTest(content = { + state = flow.collectAsStoreStateWithLifecycle(lifecycleOwner = owner) + }) { host -> + flow.emit(TestStoreResults.data(value = "a", origin = Origin.FETCHER)) + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "a" } + owner.moveTo(Lifecycle.State.CREATED) + host.advanceFrame() + owner.moveTo(Lifecycle.State.STARTED) + repeat(5) { host.advanceFrame() } + assertTrue( + state.value is StoreResult.Data<*>, + "re-entry discarded the retained result: ${state.value}", + ) + assertEquals("a", (state.value as StoreResult.Data).value) + } + } + + @Test + fun flowVariantPausesBelowMinActiveStateAndResumes(): TestResult { + val flow = MutableSharedFlow>(replay = 1) + val owner = TestOwner().apply { moveTo(Lifecycle.State.STARTED) } + val seeded = TestStoreResults.data(value = "seed", origin = Origin.MEMORY) + lateinit var state: State> + return runComposeTest(content = { + state = flow.collectAsStoreStateWithLifecycle(initial = seeded, lifecycleOwner = owner) + }) { host -> + assertSame(seeded, state.value) + flow.emit(TestStoreResults.data(value = "a", origin = Origin.FETCHER)) + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "a" } + owner.moveTo(Lifecycle.State.CREATED) + host.advanceFrame() + flow.emit(TestStoreResults.data(value = "b", origin = Origin.FETCHER)) + host.advanceFrame() + host.advanceFrame() + assertEquals("a", (state.value as StoreResult.Data).value) // paused, not collected + owner.moveTo(Lifecycle.State.STARTED) + host.awaitUntil { (state.value as? StoreResult.Data)?.value == "b" } + } + } +} diff --git a/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ComposeTestHarness.kt b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ComposeTestHarness.kt new file mode 100644 index 000000000..cff723e70 --- /dev/null +++ b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/ComposeTestHarness.kt @@ -0,0 +1,68 @@ +package org.mobilenativefoundation.store6.compose + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.test.runTest as coroutineRunTest + +private object UnitApplier : AbstractApplier(Unit) { + override fun insertBottomUp(index: Int, instance: Unit) {} + + override fun insertTopDown(index: Int, instance: Unit) {} + + override fun move(from: Int, to: Int, count: Int) {} + + override fun remove(index: Int, count: Int) {} + + override fun onClear() {} +} + +/** + * Drives a composition entirely from the test scheduler: no wall clock, no `Dispatchers.Default` + * hop, no nested `withTimeout`. Every frame is pumped explicitly through [advanceFrame]. + */ +internal class ComposeHost(private val scope: TestScope, private val clock: BroadcastFrameClock) { + fun advanceFrame() { + Snapshot.sendApplyNotifications() + scope.testScheduler.runCurrent() + clock.sendFrame(0L) + scope.testScheduler.runCurrent() + } + + fun awaitUntil(limit: Int = 50, predicate: () -> Boolean) { + repeat(limit) { if (predicate()) return else advanceFrame() } + check(predicate()) { "condition not reached within $limit frames" } + } +} + +internal fun runComposeTest( + content: @Composable () -> Unit, + block: suspend TestScope.(ComposeHost) -> Unit, +): TestResult = runTest { + val clock = BroadcastFrameClock() + val recomposer = Recomposer(coroutineContext + clock) + val runner = launch(clock) { recomposer.runRecomposeAndApplyChanges() } + val composition = Composition(UnitApplier, recomposer) + val host = ComposeHost(this, clock) + try { + composition.setContent(content) + host.advanceFrame() + block(host) + } finally { + composition.dispose() + recomposer.close() + runner.cancelAndJoin() + } +} + +// One file-private 25s runTest shadow, no nested wall-clock waits. +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/SkipEqualDataTest.kt b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/SkipEqualDataTest.kt new file mode 100644 index 000000000..da05a15e5 --- /dev/null +++ b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/SkipEqualDataTest.kt @@ -0,0 +1,74 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.compose + +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.testing.TestStoreResults +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +class SkipEqualDataTest { + private fun data( + value: String, + stale: Boolean = false, + refreshing: Boolean = false, + age: Duration = Duration.ZERO, + origin: Origin = Origin.FETCHER, + ): StoreResult = TestStoreResults.data( + value = value, origin = origin, age = age, isStale = stale, refreshing = refreshing, + ) + + @Test + fun dropsStructurallyEqualConsecutiveData(): TestResult = runTest { + val out = flowOf(data("a"), data("a"), data("a"), data("b"), data("b")) + .skipEqualData().toList() + assertEquals(listOf("a", "b"), out.map { (it as StoreResult.Data).value }) + } + + @Test + fun ageIsExcludedFromTheComparison(): TestResult = runTest { + val out = flowOf(data("a", age = 0.milliseconds), data("a", age = 250.milliseconds)) + .skipEqualData().toList() + assertEquals(1, out.size) + } + + @Test + fun flagChangesAreEmitted(): TestResult = runTest { + val out = flowOf(data("a"), data("a", refreshing = true), data("a", refreshing = true, stale = true)) + .skipEqualData().toList() + assertEquals(3, out.size) + } + + @Test + fun lifecycleSignalsAlwaysPass(): TestResult = runTest { + val error = TestStoreResults.error(TestStoreResults.fetchError("boom"), servedStale = true) + val out = flowOf( + TestStoreResults.loading(), TestStoreResults.loading(), + data("a"), TestStoreResults.revalidated(1.milliseconds), + TestStoreResults.revalidated(1.milliseconds), error, error, + ).skipEqualData().toList() + assertEquals(7, out.size) + } + + @Test + fun dataSeparatedByLifecycleSignalReEmits(): TestResult = runTest { + val out = flowOf(data("a"), TestStoreResults.loading(), data("a")) + .skipEqualData().toList() + assertEquals(3, out.size) + } + + @Test + fun customValueEquivalenceIsHonored(): TestResult = runTest { + val out = flowOf(data("a"), data("A"), data("b")) + .skipEqualData { x, y -> x.equals(y, ignoreCase = true) }.toList() + assertEquals(listOf("a", "b"), out.map { (it as StoreResult.Data).value }) + } +} diff --git a/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/StoreResultMutationPolicyTest.kt b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/StoreResultMutationPolicyTest.kt new file mode 100644 index 000000000..bcd7e5b7f --- /dev/null +++ b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/StoreResultMutationPolicyTest.kt @@ -0,0 +1,59 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.compose + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.testing.TestStoreResults +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds + +class StoreResultMutationPolicyTest { + private val policy = storeResultMutationPolicy() + private fun data( + value: String, + refreshing: Boolean = false, + age: Long = 0, + origin: Origin = Origin.FETCHER, + ) = TestStoreResults.data( + value = value, origin = origin, age = age.milliseconds, + isStale = false, refreshing = refreshing, + ) + + @Test fun equivalentForStructurallyEqualDataIgnoringAge() = + assertTrue(policy.equivalent(data("a", age = 0), data("a", age = 90))) + + @Test fun notEquivalentWhenFlagsDiffer() = + assertFalse(policy.equivalent(data("a"), data("a", refreshing = true))) + + @Test fun notEquivalentAcrossVariants() = + assertFalse(policy.equivalent(data("a"), TestStoreResults.loading())) + + @Test fun distinctErrorInstancesAreNotEquivalent() { + val e1 = TestStoreResults.error(TestStoreResults.fetchError("x"), servedStale = false) + val e2 = TestStoreResults.error(TestStoreResults.fetchError("x"), servedStale = false) + assertFalse(policy.equivalent(e1, e2)) + } + + @Test fun sameInstanceIsEquivalent() { + val loading = TestStoreResults.loading() + assertTrue(policy.equivalent(loading, loading)) + } + + /** + * The memory-snapshot-then-fetch-commit sequence emits the same value under two origins; + * collapsing those would hide the origin transition from readers. + */ + @Test fun notEquivalentWhenOriginDiffers() = + assertFalse(policy.equivalent(data("a", origin = Origin.MEMORY), data("a", origin = Origin.FETCHER))) + + @Test fun customValueEquivalenceIsHonored() { + val caseInsensitive = storeResultMutationPolicy { x, y -> x.equals(y, ignoreCase = true) } + assertTrue(caseInsensitive.equivalent(data("a"), data("A"))) + assertFalse(caseInsensitive.equivalent(data("a"), data("b"))) + // The default policy must NOT treat these as equivalent — proving the custom one was used. + assertFalse(policy.equivalent(data("a"), data("A"))) + } +} diff --git a/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/docs/PendingWriteUiDocsSnippet.kt b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/docs/PendingWriteUiDocsSnippet.kt new file mode 100644 index 000000000..59df5d26f --- /dev/null +++ b/store6-compose/src/commonTest/kotlin/org/mobilenativefoundation/store6/compose/docs/PendingWriteUiDocsSnippet.kt @@ -0,0 +1,22 @@ +package org.mobilenativefoundation.store6.compose.docs + +// docs:snippet:mutations-pending-write-ui-badges +import androidx.compose.runtime.Composable +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreResult + +@Composable +fun WriteBadges( + result: StoreResult, + saving: @Composable () -> Unit, + stale: @Composable () -> Unit, +) { + when (result) { + is StoreResult.Data -> { + if (result.origin == Origin.OVERLAY) saving() + if (result.isStale) stale() + } + else -> Unit + } +} +// docs:snippet:end diff --git a/store6-compose/stability/store6-stability.conf b/store6-compose/stability/store6-stability.conf new file mode 100644 index 000000000..bc811029f --- /dev/null +++ b/store6-compose/stability/store6-stability.conf @@ -0,0 +1,9 @@ +// Store v6 stability configuration for the Compose compiler. +// Core public value types are deeply immutable by construction (vals only, gated by BCV dumps). +// Their equals is identity (documented behavioral contract), so treating them as stable never +// wrongly skips: an equals comparison can only report "unchanged" for the same instance. +// Add this file via composeCompiler.stabilityConfigurationFiles (Compose compiler 1.5.5+ / +// Kotlin 2.x compose plugin) if you are on an older baseline or want skipping without +// strong skipping's instance comparison. +org.mobilenativefoundation.store6.core.* +org.mobilenativefoundation.store6.core.seam.* diff --git a/store6-core/api/android/store6-core.api b/store6-core/api/android/store6-core.api new file mode 100644 index 000000000..fa9ffa6cc --- /dev/null +++ b/store6-core/api/android/store6-core.api @@ -0,0 +1,344 @@ +public abstract interface annotation class org/mobilenativefoundation/store6/core/DelicateStoreApi : java/lang/annotation/Annotation { +} + +public abstract interface annotation class org/mobilenativefoundation/store6/core/ExperimentalStoreApi : java/lang/annotation/Annotation { +} + +public abstract interface class org/mobilenativefoundation/store6/core/Freshness { +} + +public final class org/mobilenativefoundation/store6/core/Freshness$CachedOrFetch : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$CachedOrFetch; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/Freshness$LocalOnly : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$LocalOnly; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/Freshness$MaxAge : org/mobilenativefoundation/store6/core/Freshness { + public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getNotOlderThan-UwyO8pc ()J +} + +public final class org/mobilenativefoundation/store6/core/Freshness$MustBeFresh : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$MustBeFresh; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/Freshness$StaleIfError : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$StaleIfError; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface annotation class org/mobilenativefoundation/store6/core/InternalStoreApi : java/lang/annotation/Annotation { +} + +public final class org/mobilenativefoundation/store6/core/Origin : java/lang/Enum { + public static final field FETCHER Lorg/mobilenativefoundation/store6/core/Origin; + public static final field MEMORY Lorg/mobilenativefoundation/store6/core/Origin; + public static final field OVERLAY Lorg/mobilenativefoundation/store6/core/Origin; + public static final field SOT Lorg/mobilenativefoundation/store6/core/Origin; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/Origin; + public static fun values ()[Lorg/mobilenativefoundation/store6/core/Origin; +} + +public abstract interface class org/mobilenativefoundation/store6/core/Store { + public abstract fun clear (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun clearAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun clearNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun close ()V + public abstract fun get (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun invalidate (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun invalidateAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun invalidateNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun stream (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;)Lkotlinx/coroutines/flow/Flow; +} + +public final class org/mobilenativefoundation/store6/core/Store$DefaultImpls { + public static synthetic fun get$default (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun stream$default (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + +public final class org/mobilenativefoundation/store6/core/StoreBuilder { + public final fun bookkeeper (Lorg/mobilenativefoundation/store6/core/seam/Bookkeeper;)V + public final fun fetcher (Lkotlin/jvm/functions/Function2;)V + public final fun fetcher (Lorg/mobilenativefoundation/store6/core/seam/Fetcher;)V + public final fun fetcherOfResult (Lkotlin/jvm/functions/Function2;)V + public final fun freshnessValidator (Lorg/mobilenativefoundation/store6/core/seam/FreshnessValidator;)V + public final fun maxIdleKeys (I)V + public final fun overlay (Lorg/mobilenativefoundation/store6/core/seam/Overlay;)V + public final fun persistence (Lorg/mobilenativefoundation/store6/core/seam/SourceOfTruth;)V + public final fun telemetry (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;)V + public final fun wallClock (Lorg/mobilenativefoundation/store6/core/seam/WallClock;)V +} + +public final class org/mobilenativefoundation/store6/core/StoreBuilderKt { + public static final fun store (Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store6/core/Store; +} + +public abstract class org/mobilenativefoundation/store6/core/StoreError { +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Conflict : org/mobilenativefoundation/store6/core/StoreError { + public final fun getMessage ()Ljava/lang/String; + public final fun getServerMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Conversion : org/mobilenativefoundation/store6/core/StoreError { + public final fun getCause ()Ljava/lang/Throwable; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Fetch : org/mobilenativefoundation/store6/core/StoreError { + public final fun getCause ()Ljava/lang/Throwable; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$FreshnessUnsatisfiable : org/mobilenativefoundation/store6/core/StoreError { + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Missing : org/mobilenativefoundation/store6/core/StoreError { + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Persistence : org/mobilenativefoundation/store6/core/StoreError { + public final fun getCause ()Ljava/lang/Throwable; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreException : java/lang/RuntimeException { + public final fun getError ()Lorg/mobilenativefoundation/store6/core/StoreError; +} + +public abstract interface class org/mobilenativefoundation/store6/core/StoreKey { + public abstract fun canonicalId ()Ljava/lang/String; + public abstract fun getNamespace ()Lorg/mobilenativefoundation/store6/core/StoreNamespace; +} + +public abstract interface class org/mobilenativefoundation/store6/core/StoreMeta { + public abstract fun getEtag ()Ljava/lang/String; + public abstract fun getWrittenAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/core/StoreNamespace { + public fun (Ljava/lang/String;)V + public final fun getValue ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/core/StoreResult { +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Data : org/mobilenativefoundation/store6/core/StoreResult { + public final fun getAge-UwyO8pc ()J + public final fun getOrigin ()Lorg/mobilenativefoundation/store6/core/Origin; + public final fun getRefreshing ()Z + public final fun getValue ()Ljava/lang/Object; + public final fun isStale ()Z +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Error : org/mobilenativefoundation/store6/core/StoreResult { + public final fun getError ()Lorg/mobilenativefoundation/store6/core/StoreError; + public final fun getServedStale ()Z +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Loading : org/mobilenativefoundation/store6/core/StoreResult { +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Revalidated : org/mobilenativefoundation/store6/core/StoreResult { + public final fun getAge-UwyO8pc ()J +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/Bookkeeper { + public abstract fun advanceGlobalStaleWatermark (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun advanceStaleWatermark (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun forget (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun forgetAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun forgetNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun markStale (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun recordFailure (Lorg/mobilenativefoundation/store6/core/StoreKey;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun recordSuccess (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreMeta;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun status (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/FetchPlan { +} + +public final class org/mobilenativefoundation/store6/core/seam/FetchPlan$Conditional : org/mobilenativefoundation/store6/core/seam/FetchPlan { + public fun (Ljava/lang/String;Z)V + public final fun getEtag ()Ljava/lang/String; + public final fun getServesResidentWhileFetching ()Z +} + +public final class org/mobilenativefoundation/store6/core/seam/FetchPlan$Fetch : org/mobilenativefoundation/store6/core/seam/FetchPlan { + public fun (Z)V + public final fun getServesResidentWhileFetching ()Z +} + +public final class org/mobilenativefoundation/store6/core/seam/FetchPlan$Skip : org/mobilenativefoundation/store6/core/seam/FetchPlan { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/seam/FetchPlan$Skip; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/Fetcher { + public abstract fun fetch (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/FetcherResult { +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$Deleted : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/seam/FetcherResult$Deleted; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$Error : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public fun (Ljava/lang/Throwable;)V + public final fun getCause ()Ljava/lang/Throwable; +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$NotModified : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public fun ()V + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEtag ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$Success : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public fun (Ljava/lang/Object;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/Object;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEtag ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/core/seam/FreshnessContext { + public fun (ZLorg/mobilenativefoundation/store6/core/StoreMeta;ZLorg/mobilenativefoundation/store6/core/Freshness;JLorg/mobilenativefoundation/store6/core/seam/KeyStatus;)V + public synthetic fun (ZLorg/mobilenativefoundation/store6/core/StoreMeta;ZLorg/mobilenativefoundation/store6/core/Freshness;JLorg/mobilenativefoundation/store6/core/seam/KeyStatus;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEpochStale ()Z + public final fun getFreshness ()Lorg/mobilenativefoundation/store6/core/Freshness; + public final fun getHasResidentValue ()Z + public final fun getMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; + public final fun getNowEpochMillis ()J + public final fun getStatus ()Lorg/mobilenativefoundation/store6/core/seam/KeyStatus; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/FreshnessValidator { + public abstract fun plan (Lorg/mobilenativefoundation/store6/core/seam/FreshnessContext;)Lorg/mobilenativefoundation/store6/core/seam/FetchPlan; +} + +public abstract class org/mobilenativefoundation/store6/core/seam/KeyEvents { + public abstract fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyEvents$Deleted : org/mobilenativefoundation/store6/core/seam/KeyEvents { + public fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyEvents$Invalidated : org/mobilenativefoundation/store6/core/seam/KeyEvents { + public fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyEvents$Written : org/mobilenativefoundation/store6/core/seam/KeyEvents { + public fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun getOrigin ()Lorg/mobilenativefoundation/store6/core/Origin; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyStatus { + public fun (Lorg/mobilenativefoundation/store6/core/StoreMeta;Ljava/lang/Long;Ljava/lang/Long;IZ)V + public final fun getConsecutiveFailures ()I + public final fun getDurablyStale ()Z + public final fun getLastFailureAtEpochMillis ()Ljava/lang/Long; + public final fun getLastSuccessSequence ()Ljava/lang/Long; + public final fun getMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/Overlay { + public abstract fun apply (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;)Ljava/lang/Object; + public abstract fun getChanges ()Lkotlinx/coroutines/flow/Flow; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/SourceOfTruth { + public abstract fun delete (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun reader (Lorg/mobilenativefoundation/store6/core/StoreKey;)Lkotlinx/coroutines/flow/Flow; + public abstract fun write (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/core/seam/StoreResults { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/seam/StoreResults; + public final fun conflict (Lorg/mobilenativefoundation/store6/core/StoreMeta;Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$Conflict; + public final fun conversionError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Conversion; + public static synthetic fun conversionError$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Conversion; + public final fun data-1Y68eR8 (Ljava/lang/Object;Lorg/mobilenativefoundation/store6/core/Origin;JZZ)Lorg/mobilenativefoundation/store6/core/StoreResult$Data; + public final fun error (Lorg/mobilenativefoundation/store6/core/StoreError;Z)Lorg/mobilenativefoundation/store6/core/StoreResult$Error; + public final fun exception (Lorg/mobilenativefoundation/store6/core/StoreError;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreException; + public static synthetic fun exception$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Lorg/mobilenativefoundation/store6/core/StoreError;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreException; + public final fun fetchError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Fetch; + public static synthetic fun fetchError$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Fetch; + public final fun freshnessUnsatisfiable (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$FreshnessUnsatisfiable; + public final fun loading ()Lorg/mobilenativefoundation/store6/core/StoreResult$Loading; + public final fun missing (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$Missing; + public final fun persistenceError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Persistence; + public static synthetic fun persistenceError$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Persistence; + public final fun revalidated-LRDsOJo (J)Lorg/mobilenativefoundation/store6/core/StoreResult$Revalidated; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/StoreRuntime { + public abstract fun getKeyEvents ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getTelemetry ()Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry; + public abstract fun getWriteHandle ()Lorg/mobilenativefoundation/store6/core/seam/StoreWriteHandle; +} + +public final class org/mobilenativefoundation/store6/core/seam/StoreRuntimeKt { + public static final fun runtime (Lorg/mobilenativefoundation/store6/core/Store;)Lorg/mobilenativefoundation/store6/core/seam/StoreRuntime; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/StoreTelemetry { + public abstract fun onCleared (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public abstract fun onFetchFailed-SxA4cEA (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreError;J)V + public abstract fun onFetchStarted (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public abstract fun onFetchSucceeded-HG0u8IE (Lorg/mobilenativefoundation/store6/core/StoreKey;J)V + public abstract fun onInvalidated (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public abstract fun onServe (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Origin;)V +} + +public final class org/mobilenativefoundation/store6/core/seam/StoreTelemetry$DefaultImpls { + public static fun onCleared (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public static fun onFetchFailed-SxA4cEA (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreError;J)V + public static fun onFetchStarted (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public static fun onFetchSucceeded-HG0u8IE (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;J)V + public static fun onInvalidated (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public static fun onServe (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Origin;)V +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/StoreWriteHandle { + public abstract fun apply (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun confirmFresh (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun markStale (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth : org/mobilenativefoundation/store6/core/seam/SourceOfTruth { + public abstract fun withTransaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/WallClock { + public abstract fun nowEpochMillis ()J +} + diff --git a/store6-core/api/jvm/store6-core.api b/store6-core/api/jvm/store6-core.api new file mode 100644 index 000000000..fa9ffa6cc --- /dev/null +++ b/store6-core/api/jvm/store6-core.api @@ -0,0 +1,344 @@ +public abstract interface annotation class org/mobilenativefoundation/store6/core/DelicateStoreApi : java/lang/annotation/Annotation { +} + +public abstract interface annotation class org/mobilenativefoundation/store6/core/ExperimentalStoreApi : java/lang/annotation/Annotation { +} + +public abstract interface class org/mobilenativefoundation/store6/core/Freshness { +} + +public final class org/mobilenativefoundation/store6/core/Freshness$CachedOrFetch : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$CachedOrFetch; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/Freshness$LocalOnly : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$LocalOnly; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/Freshness$MaxAge : org/mobilenativefoundation/store6/core/Freshness { + public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getNotOlderThan-UwyO8pc ()J +} + +public final class org/mobilenativefoundation/store6/core/Freshness$MustBeFresh : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$MustBeFresh; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/Freshness$StaleIfError : org/mobilenativefoundation/store6/core/Freshness { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/Freshness$StaleIfError; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface annotation class org/mobilenativefoundation/store6/core/InternalStoreApi : java/lang/annotation/Annotation { +} + +public final class org/mobilenativefoundation/store6/core/Origin : java/lang/Enum { + public static final field FETCHER Lorg/mobilenativefoundation/store6/core/Origin; + public static final field MEMORY Lorg/mobilenativefoundation/store6/core/Origin; + public static final field OVERLAY Lorg/mobilenativefoundation/store6/core/Origin; + public static final field SOT Lorg/mobilenativefoundation/store6/core/Origin; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/Origin; + public static fun values ()[Lorg/mobilenativefoundation/store6/core/Origin; +} + +public abstract interface class org/mobilenativefoundation/store6/core/Store { + public abstract fun clear (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun clearAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun clearNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun close ()V + public abstract fun get (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun invalidate (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun invalidateAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun invalidateNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun stream (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;)Lkotlinx/coroutines/flow/Flow; +} + +public final class org/mobilenativefoundation/store6/core/Store$DefaultImpls { + public static synthetic fun get$default (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun stream$default (Lorg/mobilenativefoundation/store6/core/Store;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Freshness;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + +public final class org/mobilenativefoundation/store6/core/StoreBuilder { + public final fun bookkeeper (Lorg/mobilenativefoundation/store6/core/seam/Bookkeeper;)V + public final fun fetcher (Lkotlin/jvm/functions/Function2;)V + public final fun fetcher (Lorg/mobilenativefoundation/store6/core/seam/Fetcher;)V + public final fun fetcherOfResult (Lkotlin/jvm/functions/Function2;)V + public final fun freshnessValidator (Lorg/mobilenativefoundation/store6/core/seam/FreshnessValidator;)V + public final fun maxIdleKeys (I)V + public final fun overlay (Lorg/mobilenativefoundation/store6/core/seam/Overlay;)V + public final fun persistence (Lorg/mobilenativefoundation/store6/core/seam/SourceOfTruth;)V + public final fun telemetry (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;)V + public final fun wallClock (Lorg/mobilenativefoundation/store6/core/seam/WallClock;)V +} + +public final class org/mobilenativefoundation/store6/core/StoreBuilderKt { + public static final fun store (Lkotlin/jvm/functions/Function1;)Lorg/mobilenativefoundation/store6/core/Store; +} + +public abstract class org/mobilenativefoundation/store6/core/StoreError { +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Conflict : org/mobilenativefoundation/store6/core/StoreError { + public final fun getMessage ()Ljava/lang/String; + public final fun getServerMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Conversion : org/mobilenativefoundation/store6/core/StoreError { + public final fun getCause ()Ljava/lang/Throwable; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Fetch : org/mobilenativefoundation/store6/core/StoreError { + public final fun getCause ()Ljava/lang/Throwable; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$FreshnessUnsatisfiable : org/mobilenativefoundation/store6/core/StoreError { + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Missing : org/mobilenativefoundation/store6/core/StoreError { + public final fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreError$Persistence : org/mobilenativefoundation/store6/core/StoreError { + public final fun getCause ()Ljava/lang/Throwable; + public final fun getMessage ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/StoreException : java/lang/RuntimeException { + public final fun getError ()Lorg/mobilenativefoundation/store6/core/StoreError; +} + +public abstract interface class org/mobilenativefoundation/store6/core/StoreKey { + public abstract fun canonicalId ()Ljava/lang/String; + public abstract fun getNamespace ()Lorg/mobilenativefoundation/store6/core/StoreNamespace; +} + +public abstract interface class org/mobilenativefoundation/store6/core/StoreMeta { + public abstract fun getEtag ()Ljava/lang/String; + public abstract fun getWrittenAtEpochMillis ()J +} + +public final class org/mobilenativefoundation/store6/core/StoreNamespace { + public fun (Ljava/lang/String;)V + public final fun getValue ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/core/StoreResult { +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Data : org/mobilenativefoundation/store6/core/StoreResult { + public final fun getAge-UwyO8pc ()J + public final fun getOrigin ()Lorg/mobilenativefoundation/store6/core/Origin; + public final fun getRefreshing ()Z + public final fun getValue ()Ljava/lang/Object; + public final fun isStale ()Z +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Error : org/mobilenativefoundation/store6/core/StoreResult { + public final fun getError ()Lorg/mobilenativefoundation/store6/core/StoreError; + public final fun getServedStale ()Z +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Loading : org/mobilenativefoundation/store6/core/StoreResult { +} + +public final class org/mobilenativefoundation/store6/core/StoreResult$Revalidated : org/mobilenativefoundation/store6/core/StoreResult { + public final fun getAge-UwyO8pc ()J +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/Bookkeeper { + public abstract fun advanceGlobalStaleWatermark (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun advanceStaleWatermark (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun forget (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun forgetAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun forgetNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun markStale (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun recordFailure (Lorg/mobilenativefoundation/store6/core/StoreKey;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun recordSuccess (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreMeta;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun status (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/FetchPlan { +} + +public final class org/mobilenativefoundation/store6/core/seam/FetchPlan$Conditional : org/mobilenativefoundation/store6/core/seam/FetchPlan { + public fun (Ljava/lang/String;Z)V + public final fun getEtag ()Ljava/lang/String; + public final fun getServesResidentWhileFetching ()Z +} + +public final class org/mobilenativefoundation/store6/core/seam/FetchPlan$Fetch : org/mobilenativefoundation/store6/core/seam/FetchPlan { + public fun (Z)V + public final fun getServesResidentWhileFetching ()Z +} + +public final class org/mobilenativefoundation/store6/core/seam/FetchPlan$Skip : org/mobilenativefoundation/store6/core/seam/FetchPlan { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/seam/FetchPlan$Skip; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/Fetcher { + public abstract fun fetch (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/FetcherResult { +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$Deleted : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/seam/FetcherResult$Deleted; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$Error : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public fun (Ljava/lang/Throwable;)V + public final fun getCause ()Ljava/lang/Throwable; +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$NotModified : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public fun ()V + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEtag ()Ljava/lang/String; +} + +public final class org/mobilenativefoundation/store6/core/seam/FetcherResult$Success : org/mobilenativefoundation/store6/core/seam/FetcherResult { + public fun (Ljava/lang/Object;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/Object;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEtag ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/core/seam/FreshnessContext { + public fun (ZLorg/mobilenativefoundation/store6/core/StoreMeta;ZLorg/mobilenativefoundation/store6/core/Freshness;JLorg/mobilenativefoundation/store6/core/seam/KeyStatus;)V + public synthetic fun (ZLorg/mobilenativefoundation/store6/core/StoreMeta;ZLorg/mobilenativefoundation/store6/core/Freshness;JLorg/mobilenativefoundation/store6/core/seam/KeyStatus;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getEpochStale ()Z + public final fun getFreshness ()Lorg/mobilenativefoundation/store6/core/Freshness; + public final fun getHasResidentValue ()Z + public final fun getMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; + public final fun getNowEpochMillis ()J + public final fun getStatus ()Lorg/mobilenativefoundation/store6/core/seam/KeyStatus; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/FreshnessValidator { + public abstract fun plan (Lorg/mobilenativefoundation/store6/core/seam/FreshnessContext;)Lorg/mobilenativefoundation/store6/core/seam/FetchPlan; +} + +public abstract class org/mobilenativefoundation/store6/core/seam/KeyEvents { + public abstract fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyEvents$Deleted : org/mobilenativefoundation/store6/core/seam/KeyEvents { + public fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyEvents$Invalidated : org/mobilenativefoundation/store6/core/seam/KeyEvents { + public fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyEvents$Written : org/mobilenativefoundation/store6/core/seam/KeyEvents { + public fun getKey ()Lorg/mobilenativefoundation/store6/core/StoreKey; + public final fun getOrigin ()Lorg/mobilenativefoundation/store6/core/Origin; +} + +public final class org/mobilenativefoundation/store6/core/seam/KeyStatus { + public fun (Lorg/mobilenativefoundation/store6/core/StoreMeta;Ljava/lang/Long;Ljava/lang/Long;IZ)V + public final fun getConsecutiveFailures ()I + public final fun getDurablyStale ()Z + public final fun getLastFailureAtEpochMillis ()Ljava/lang/Long; + public final fun getLastSuccessSequence ()Ljava/lang/Long; + public final fun getMeta ()Lorg/mobilenativefoundation/store6/core/StoreMeta; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/Overlay { + public abstract fun apply (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;)Ljava/lang/Object; + public abstract fun getChanges ()Lkotlinx/coroutines/flow/Flow; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/SourceOfTruth { + public abstract fun delete (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteNamespace (Lorg/mobilenativefoundation/store6/core/StoreNamespace;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun reader (Lorg/mobilenativefoundation/store6/core/StoreKey;)Lkotlinx/coroutines/flow/Flow; + public abstract fun write (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/mobilenativefoundation/store6/core/seam/StoreResults { + public static final field INSTANCE Lorg/mobilenativefoundation/store6/core/seam/StoreResults; + public final fun conflict (Lorg/mobilenativefoundation/store6/core/StoreMeta;Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$Conflict; + public final fun conversionError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Conversion; + public static synthetic fun conversionError$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Conversion; + public final fun data-1Y68eR8 (Ljava/lang/Object;Lorg/mobilenativefoundation/store6/core/Origin;JZZ)Lorg/mobilenativefoundation/store6/core/StoreResult$Data; + public final fun error (Lorg/mobilenativefoundation/store6/core/StoreError;Z)Lorg/mobilenativefoundation/store6/core/StoreResult$Error; + public final fun exception (Lorg/mobilenativefoundation/store6/core/StoreError;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreException; + public static synthetic fun exception$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Lorg/mobilenativefoundation/store6/core/StoreError;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreException; + public final fun fetchError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Fetch; + public static synthetic fun fetchError$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Fetch; + public final fun freshnessUnsatisfiable (Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$FreshnessUnsatisfiable; + public final fun loading ()Lorg/mobilenativefoundation/store6/core/StoreResult$Loading; + public final fun missing (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;)Lorg/mobilenativefoundation/store6/core/StoreError$Missing; + public final fun persistenceError (Ljava/lang/String;Ljava/lang/Throwable;)Lorg/mobilenativefoundation/store6/core/StoreError$Persistence; + public static synthetic fun persistenceError$default (Lorg/mobilenativefoundation/store6/core/seam/StoreResults;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)Lorg/mobilenativefoundation/store6/core/StoreError$Persistence; + public final fun revalidated-LRDsOJo (J)Lorg/mobilenativefoundation/store6/core/StoreResult$Revalidated; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/StoreRuntime { + public abstract fun getKeyEvents ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getTelemetry ()Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry; + public abstract fun getWriteHandle ()Lorg/mobilenativefoundation/store6/core/seam/StoreWriteHandle; +} + +public final class org/mobilenativefoundation/store6/core/seam/StoreRuntimeKt { + public static final fun runtime (Lorg/mobilenativefoundation/store6/core/Store;)Lorg/mobilenativefoundation/store6/core/seam/StoreRuntime; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/StoreTelemetry { + public abstract fun onCleared (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public abstract fun onFetchFailed-SxA4cEA (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreError;J)V + public abstract fun onFetchStarted (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public abstract fun onFetchSucceeded-HG0u8IE (Lorg/mobilenativefoundation/store6/core/StoreKey;J)V + public abstract fun onInvalidated (Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public abstract fun onServe (Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Origin;)V +} + +public final class org/mobilenativefoundation/store6/core/seam/StoreTelemetry$DefaultImpls { + public static fun onCleared (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public static fun onFetchFailed-SxA4cEA (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/StoreError;J)V + public static fun onFetchStarted (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public static fun onFetchSucceeded-HG0u8IE (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;J)V + public static fun onInvalidated (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;)V + public static fun onServe (Lorg/mobilenativefoundation/store6/core/seam/StoreTelemetry;Lorg/mobilenativefoundation/store6/core/StoreKey;Lorg/mobilenativefoundation/store6/core/Origin;)V +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/StoreWriteHandle { + public abstract fun apply (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun confirmFresh (Lorg/mobilenativefoundation/store6/core/StoreKey;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun markStale (Lorg/mobilenativefoundation/store6/core/StoreKey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth : org/mobilenativefoundation/store6/core/seam/SourceOfTruth { + public abstract fun withTransaction (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class org/mobilenativefoundation/store6/core/seam/WallClock { + public abstract fun nowEpochMillis ()J +} + diff --git a/store6-core/api/store6-core.klib.api b/store6-core/api/store6-core.klib.api new file mode 100644 index 000000000..05c34bcc4 --- /dev/null +++ b/store6-core/api/store6-core.klib.api @@ -0,0 +1,380 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, linuxX64, macosArm64, mingwX64, tvosArm64, wasmJs, watchosArm64] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +open annotation class org.mobilenativefoundation.store6.core/DelicateStoreApi : kotlin/Annotation { // org.mobilenativefoundation.store6.core/DelicateStoreApi|null[0] + constructor () // org.mobilenativefoundation.store6.core/DelicateStoreApi.|(){}[0] +} + +open annotation class org.mobilenativefoundation.store6.core/ExperimentalStoreApi : kotlin/Annotation { // org.mobilenativefoundation.store6.core/ExperimentalStoreApi|null[0] + constructor () // org.mobilenativefoundation.store6.core/ExperimentalStoreApi.|(){}[0] +} + +open annotation class org.mobilenativefoundation.store6.core/InternalStoreApi : kotlin/Annotation { // org.mobilenativefoundation.store6.core/InternalStoreApi|null[0] + constructor () // org.mobilenativefoundation.store6.core/InternalStoreApi.|(){}[0] +} + +final enum class org.mobilenativefoundation.store6.core/Origin : kotlin/Enum { // org.mobilenativefoundation.store6.core/Origin|null[0] + enum entry FETCHER // org.mobilenativefoundation.store6.core/Origin.FETCHER|null[0] + enum entry MEMORY // org.mobilenativefoundation.store6.core/Origin.MEMORY|null[0] + enum entry OVERLAY // org.mobilenativefoundation.store6.core/Origin.OVERLAY|null[0] + enum entry SOT // org.mobilenativefoundation.store6.core/Origin.SOT|null[0] + + final val entries // org.mobilenativefoundation.store6.core/Origin.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.mobilenativefoundation.store6.core/Origin.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.mobilenativefoundation.store6.core/Origin // org.mobilenativefoundation.store6.core/Origin.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.mobilenativefoundation.store6.core/Origin.values|values#static(){}[0] +} + +abstract interface <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core.seam/Fetcher { // org.mobilenativefoundation.store6.core.seam/Fetcher|null[0] + abstract suspend fun fetch(#A, kotlin/String?): org.mobilenativefoundation.store6.core.seam/FetcherResult<#B> // org.mobilenativefoundation.store6.core.seam/Fetcher.fetch|fetch(1:0;kotlin.String?){}[0] +} + +abstract interface <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core.seam/Overlay { // org.mobilenativefoundation.store6.core.seam/Overlay|null[0] + abstract val changes // org.mobilenativefoundation.store6.core.seam/Overlay.changes|{}changes[0] + abstract fun (): kotlinx.coroutines.flow/Flow // org.mobilenativefoundation.store6.core.seam/Overlay.changes.|(){}[0] + + abstract fun apply(#A, #B?): #B? // org.mobilenativefoundation.store6.core.seam/Overlay.apply|apply(1:0;1:1?){}[0] +} + +abstract interface <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core.seam/SourceOfTruth { // org.mobilenativefoundation.store6.core.seam/SourceOfTruth|null[0] + abstract fun reader(#A): kotlinx.coroutines.flow/Flow<#B?> // org.mobilenativefoundation.store6.core.seam/SourceOfTruth.reader|reader(1:0){}[0] + abstract suspend fun delete(#A) // org.mobilenativefoundation.store6.core.seam/SourceOfTruth.delete|delete(1:0){}[0] + abstract suspend fun deleteAll() // org.mobilenativefoundation.store6.core.seam/SourceOfTruth.deleteAll|deleteAll(){}[0] + abstract suspend fun deleteNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.core.seam/SourceOfTruth.deleteNamespace|deleteNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + abstract suspend fun write(#A, #B) // org.mobilenativefoundation.store6.core.seam/SourceOfTruth.write|write(1:0;1:1){}[0] +} + +abstract interface <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core.seam/StoreRuntime { // org.mobilenativefoundation.store6.core.seam/StoreRuntime|null[0] + abstract val keyEvents // org.mobilenativefoundation.store6.core.seam/StoreRuntime.keyEvents|{}keyEvents[0] + abstract fun (): kotlinx.coroutines.flow/Flow // org.mobilenativefoundation.store6.core.seam/StoreRuntime.keyEvents.|(){}[0] + abstract val telemetry // org.mobilenativefoundation.store6.core.seam/StoreRuntime.telemetry|{}telemetry[0] + abstract fun (): org.mobilenativefoundation.store6.core.seam/StoreTelemetry? // org.mobilenativefoundation.store6.core.seam/StoreRuntime.telemetry.|(){}[0] + abstract val writeHandle // org.mobilenativefoundation.store6.core.seam/StoreRuntime.writeHandle|{}writeHandle[0] + abstract fun (): org.mobilenativefoundation.store6.core.seam/StoreWriteHandle<#A, #B> // org.mobilenativefoundation.store6.core.seam/StoreRuntime.writeHandle.|(){}[0] +} + +abstract interface <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core.seam/StoreWriteHandle { // org.mobilenativefoundation.store6.core.seam/StoreWriteHandle|null[0] + abstract suspend fun apply(#A, #B) // org.mobilenativefoundation.store6.core.seam/StoreWriteHandle.apply|apply(1:0;1:1){}[0] + abstract suspend fun confirmFresh(#A, kotlin/String?) // org.mobilenativefoundation.store6.core.seam/StoreWriteHandle.confirmFresh|confirmFresh(1:0;kotlin.String?){}[0] + abstract suspend fun markStale(#A) // org.mobilenativefoundation.store6.core.seam/StoreWriteHandle.markStale|markStale(1:0){}[0] +} + +abstract interface <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core.seam/TransactionalSourceOfTruth : org.mobilenativefoundation.store6.core.seam/SourceOfTruth<#A, #B> { // org.mobilenativefoundation.store6.core.seam/TransactionalSourceOfTruth|null[0] + abstract suspend fun <#A1: kotlin/Any?> withTransaction(kotlin.coroutines/SuspendFunction0<#A1>): #A1 // org.mobilenativefoundation.store6.core.seam/TransactionalSourceOfTruth.withTransaction|withTransaction(kotlin.coroutines.SuspendFunction0<0:0>){0§}[0] +} + +abstract interface <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: out kotlin/Any> org.mobilenativefoundation.store6.core/Store { // org.mobilenativefoundation.store6.core/Store|null[0] + abstract fun close() // org.mobilenativefoundation.store6.core/Store.close|close(){}[0] + abstract fun stream(#A, org.mobilenativefoundation.store6.core/Freshness = ...): kotlinx.coroutines.flow/Flow> // org.mobilenativefoundation.store6.core/Store.stream|stream(1:0;org.mobilenativefoundation.store6.core.Freshness){}[0] + abstract suspend fun clear(#A) // org.mobilenativefoundation.store6.core/Store.clear|clear(1:0){}[0] + abstract suspend fun clearAll() // org.mobilenativefoundation.store6.core/Store.clearAll|clearAll(){}[0] + abstract suspend fun clearNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.core/Store.clearNamespace|clearNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + abstract suspend fun get(#A, org.mobilenativefoundation.store6.core/Freshness = ...): #B // org.mobilenativefoundation.store6.core/Store.get|get(1:0;org.mobilenativefoundation.store6.core.Freshness){}[0] + abstract suspend fun invalidate(#A) // org.mobilenativefoundation.store6.core/Store.invalidate|invalidate(1:0){}[0] + abstract suspend fun invalidateAll() // org.mobilenativefoundation.store6.core/Store.invalidateAll|invalidateAll(){}[0] + abstract suspend fun invalidateNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.core/Store.invalidateNamespace|invalidateNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] +} + +abstract interface org.mobilenativefoundation.store6.core.seam/Bookkeeper { // org.mobilenativefoundation.store6.core.seam/Bookkeeper|null[0] + abstract suspend fun advanceGlobalStaleWatermark() // org.mobilenativefoundation.store6.core.seam/Bookkeeper.advanceGlobalStaleWatermark|advanceGlobalStaleWatermark(){}[0] + abstract suspend fun advanceStaleWatermark(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.core.seam/Bookkeeper.advanceStaleWatermark|advanceStaleWatermark(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + abstract suspend fun forget(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.core.seam/Bookkeeper.forget|forget(org.mobilenativefoundation.store6.core.StoreKey){}[0] + abstract suspend fun forgetAll() // org.mobilenativefoundation.store6.core.seam/Bookkeeper.forgetAll|forgetAll(){}[0] + abstract suspend fun forgetNamespace(org.mobilenativefoundation.store6.core/StoreNamespace) // org.mobilenativefoundation.store6.core.seam/Bookkeeper.forgetNamespace|forgetNamespace(org.mobilenativefoundation.store6.core.StoreNamespace){}[0] + abstract suspend fun markStale(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.core.seam/Bookkeeper.markStale|markStale(org.mobilenativefoundation.store6.core.StoreKey){}[0] + abstract suspend fun recordFailure(org.mobilenativefoundation.store6.core/StoreKey, kotlin/Long) // org.mobilenativefoundation.store6.core.seam/Bookkeeper.recordFailure|recordFailure(org.mobilenativefoundation.store6.core.StoreKey;kotlin.Long){}[0] + abstract suspend fun recordSuccess(org.mobilenativefoundation.store6.core/StoreKey, org.mobilenativefoundation.store6.core/StoreMeta) // org.mobilenativefoundation.store6.core.seam/Bookkeeper.recordSuccess|recordSuccess(org.mobilenativefoundation.store6.core.StoreKey;org.mobilenativefoundation.store6.core.StoreMeta){}[0] + abstract suspend fun status(org.mobilenativefoundation.store6.core/StoreKey): org.mobilenativefoundation.store6.core.seam/KeyStatus? // org.mobilenativefoundation.store6.core.seam/Bookkeeper.status|status(org.mobilenativefoundation.store6.core.StoreKey){}[0] +} + +abstract interface org.mobilenativefoundation.store6.core.seam/FreshnessValidator { // org.mobilenativefoundation.store6.core.seam/FreshnessValidator|null[0] + abstract fun plan(org.mobilenativefoundation.store6.core.seam/FreshnessContext): org.mobilenativefoundation.store6.core.seam/FetchPlan // org.mobilenativefoundation.store6.core.seam/FreshnessValidator.plan|plan(org.mobilenativefoundation.store6.core.seam.FreshnessContext){}[0] +} + +abstract interface org.mobilenativefoundation.store6.core.seam/StoreTelemetry { // org.mobilenativefoundation.store6.core.seam/StoreTelemetry|null[0] + open fun onCleared(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.core.seam/StoreTelemetry.onCleared|onCleared(org.mobilenativefoundation.store6.core.StoreKey){}[0] + open fun onFetchFailed(org.mobilenativefoundation.store6.core/StoreKey, org.mobilenativefoundation.store6.core/StoreError, kotlin.time/Duration) // org.mobilenativefoundation.store6.core.seam/StoreTelemetry.onFetchFailed|onFetchFailed(org.mobilenativefoundation.store6.core.StoreKey;org.mobilenativefoundation.store6.core.StoreError;kotlin.time.Duration){}[0] + open fun onFetchStarted(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.core.seam/StoreTelemetry.onFetchStarted|onFetchStarted(org.mobilenativefoundation.store6.core.StoreKey){}[0] + open fun onFetchSucceeded(org.mobilenativefoundation.store6.core/StoreKey, kotlin.time/Duration) // org.mobilenativefoundation.store6.core.seam/StoreTelemetry.onFetchSucceeded|onFetchSucceeded(org.mobilenativefoundation.store6.core.StoreKey;kotlin.time.Duration){}[0] + open fun onInvalidated(org.mobilenativefoundation.store6.core/StoreKey) // org.mobilenativefoundation.store6.core.seam/StoreTelemetry.onInvalidated|onInvalidated(org.mobilenativefoundation.store6.core.StoreKey){}[0] + open fun onServe(org.mobilenativefoundation.store6.core/StoreKey, org.mobilenativefoundation.store6.core/Origin) // org.mobilenativefoundation.store6.core.seam/StoreTelemetry.onServe|onServe(org.mobilenativefoundation.store6.core.StoreKey;org.mobilenativefoundation.store6.core.Origin){}[0] +} + +abstract interface org.mobilenativefoundation.store6.core.seam/WallClock { // org.mobilenativefoundation.store6.core.seam/WallClock|null[0] + abstract fun nowEpochMillis(): kotlin/Long // org.mobilenativefoundation.store6.core.seam/WallClock.nowEpochMillis|nowEpochMillis(){}[0] +} + +abstract interface org.mobilenativefoundation.store6.core/StoreKey { // org.mobilenativefoundation.store6.core/StoreKey|null[0] + abstract val namespace // org.mobilenativefoundation.store6.core/StoreKey.namespace|{}namespace[0] + abstract fun (): org.mobilenativefoundation.store6.core/StoreNamespace // org.mobilenativefoundation.store6.core/StoreKey.namespace.|(){}[0] + + abstract fun canonicalId(): kotlin/String // org.mobilenativefoundation.store6.core/StoreKey.canonicalId|canonicalId(){}[0] +} + +abstract interface org.mobilenativefoundation.store6.core/StoreMeta { // org.mobilenativefoundation.store6.core/StoreMeta|null[0] + abstract val etag // org.mobilenativefoundation.store6.core/StoreMeta.etag|{}etag[0] + abstract fun (): kotlin/String? // org.mobilenativefoundation.store6.core/StoreMeta.etag.|(){}[0] + abstract val writtenAtEpochMillis // org.mobilenativefoundation.store6.core/StoreMeta.writtenAtEpochMillis|{}writtenAtEpochMillis[0] + abstract fun (): kotlin/Long // org.mobilenativefoundation.store6.core/StoreMeta.writtenAtEpochMillis.|(){}[0] +} + +sealed interface <#A: out kotlin/Any> org.mobilenativefoundation.store6.core.seam/FetcherResult { // org.mobilenativefoundation.store6.core.seam/FetcherResult|null[0] + final class <#A1: kotlin/Any> Success : org.mobilenativefoundation.store6.core.seam/FetcherResult<#A1> { // org.mobilenativefoundation.store6.core.seam/FetcherResult.Success|null[0] + constructor (#A1, kotlin/String? = ...) // org.mobilenativefoundation.store6.core.seam/FetcherResult.Success.|(1:0;kotlin.String?){}[0] + + final val etag // org.mobilenativefoundation.store6.core.seam/FetcherResult.Success.etag|{}etag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.core.seam/FetcherResult.Success.etag.|(){}[0] + final val value // org.mobilenativefoundation.store6.core.seam/FetcherResult.Success.value|{}value[0] + final fun (): #A1 // org.mobilenativefoundation.store6.core.seam/FetcherResult.Success.value.|(){}[0] + } + + final class Error : org.mobilenativefoundation.store6.core.seam/FetcherResult { // org.mobilenativefoundation.store6.core.seam/FetcherResult.Error|null[0] + constructor (kotlin/Throwable) // org.mobilenativefoundation.store6.core.seam/FetcherResult.Error.|(kotlin.Throwable){}[0] + + final val cause // org.mobilenativefoundation.store6.core.seam/FetcherResult.Error.cause|{}cause[0] + final fun (): kotlin/Throwable // org.mobilenativefoundation.store6.core.seam/FetcherResult.Error.cause.|(){}[0] + } + + final class NotModified : org.mobilenativefoundation.store6.core.seam/FetcherResult { // org.mobilenativefoundation.store6.core.seam/FetcherResult.NotModified|null[0] + constructor (kotlin/String? = ...) // org.mobilenativefoundation.store6.core.seam/FetcherResult.NotModified.|(kotlin.String?){}[0] + + final val etag // org.mobilenativefoundation.store6.core.seam/FetcherResult.NotModified.etag|{}etag[0] + final fun (): kotlin/String? // org.mobilenativefoundation.store6.core.seam/FetcherResult.NotModified.etag.|(){}[0] + } + + final object Deleted : org.mobilenativefoundation.store6.core.seam/FetcherResult { // org.mobilenativefoundation.store6.core.seam/FetcherResult.Deleted|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.core.seam/FetcherResult.Deleted.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.core.seam/FetcherResult.Deleted.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.core.seam/FetcherResult.Deleted.toString|toString(){}[0] + } +} + +sealed interface <#A: out kotlin/Any?> org.mobilenativefoundation.store6.core/StoreResult { // org.mobilenativefoundation.store6.core/StoreResult|null[0] + final class <#A1: kotlin/Any?> Data : org.mobilenativefoundation.store6.core/StoreResult<#A1> { // org.mobilenativefoundation.store6.core/StoreResult.Data|null[0] + final val age // org.mobilenativefoundation.store6.core/StoreResult.Data.age|{}age[0] + final fun (): kotlin.time/Duration // org.mobilenativefoundation.store6.core/StoreResult.Data.age.|(){}[0] + final val isStale // org.mobilenativefoundation.store6.core/StoreResult.Data.isStale|{}isStale[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core/StoreResult.Data.isStale.|(){}[0] + final val origin // org.mobilenativefoundation.store6.core/StoreResult.Data.origin|{}origin[0] + final fun (): org.mobilenativefoundation.store6.core/Origin // org.mobilenativefoundation.store6.core/StoreResult.Data.origin.|(){}[0] + final val refreshing // org.mobilenativefoundation.store6.core/StoreResult.Data.refreshing|{}refreshing[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core/StoreResult.Data.refreshing.|(){}[0] + final val value // org.mobilenativefoundation.store6.core/StoreResult.Data.value|{}value[0] + final fun (): #A1 // org.mobilenativefoundation.store6.core/StoreResult.Data.value.|(){}[0] + } + + final class Error : org.mobilenativefoundation.store6.core/StoreResult { // org.mobilenativefoundation.store6.core/StoreResult.Error|null[0] + final val error // org.mobilenativefoundation.store6.core/StoreResult.Error.error|{}error[0] + final fun (): org.mobilenativefoundation.store6.core/StoreError // org.mobilenativefoundation.store6.core/StoreResult.Error.error.|(){}[0] + final val servedStale // org.mobilenativefoundation.store6.core/StoreResult.Error.servedStale|{}servedStale[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core/StoreResult.Error.servedStale.|(){}[0] + } + + final class Loading : org.mobilenativefoundation.store6.core/StoreResult // org.mobilenativefoundation.store6.core/StoreResult.Loading|null[0] + + final class Revalidated : org.mobilenativefoundation.store6.core/StoreResult { // org.mobilenativefoundation.store6.core/StoreResult.Revalidated|null[0] + final val age // org.mobilenativefoundation.store6.core/StoreResult.Revalidated.age|{}age[0] + final fun (): kotlin.time/Duration // org.mobilenativefoundation.store6.core/StoreResult.Revalidated.age.|(){}[0] + } +} + +sealed interface org.mobilenativefoundation.store6.core.seam/FetchPlan { // org.mobilenativefoundation.store6.core.seam/FetchPlan|null[0] + final class Conditional : org.mobilenativefoundation.store6.core.seam/FetchPlan { // org.mobilenativefoundation.store6.core.seam/FetchPlan.Conditional|null[0] + constructor (kotlin/String, kotlin/Boolean) // org.mobilenativefoundation.store6.core.seam/FetchPlan.Conditional.|(kotlin.String;kotlin.Boolean){}[0] + + final val etag // org.mobilenativefoundation.store6.core.seam/FetchPlan.Conditional.etag|{}etag[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core.seam/FetchPlan.Conditional.etag.|(){}[0] + final val servesResidentWhileFetching // org.mobilenativefoundation.store6.core.seam/FetchPlan.Conditional.servesResidentWhileFetching|{}servesResidentWhileFetching[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core.seam/FetchPlan.Conditional.servesResidentWhileFetching.|(){}[0] + } + + final class Fetch : org.mobilenativefoundation.store6.core.seam/FetchPlan { // org.mobilenativefoundation.store6.core.seam/FetchPlan.Fetch|null[0] + constructor (kotlin/Boolean) // org.mobilenativefoundation.store6.core.seam/FetchPlan.Fetch.|(kotlin.Boolean){}[0] + + final val servesResidentWhileFetching // org.mobilenativefoundation.store6.core.seam/FetchPlan.Fetch.servesResidentWhileFetching|{}servesResidentWhileFetching[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core.seam/FetchPlan.Fetch.servesResidentWhileFetching.|(){}[0] + } + + final object Skip : org.mobilenativefoundation.store6.core.seam/FetchPlan { // org.mobilenativefoundation.store6.core.seam/FetchPlan.Skip|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.core.seam/FetchPlan.Skip.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.core.seam/FetchPlan.Skip.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.core.seam/FetchPlan.Skip.toString|toString(){}[0] + } +} + +sealed interface org.mobilenativefoundation.store6.core/Freshness { // org.mobilenativefoundation.store6.core/Freshness|null[0] + final class MaxAge : org.mobilenativefoundation.store6.core/Freshness { // org.mobilenativefoundation.store6.core/Freshness.MaxAge|null[0] + constructor (kotlin.time/Duration) // org.mobilenativefoundation.store6.core/Freshness.MaxAge.|(kotlin.time.Duration){}[0] + + final val notOlderThan // org.mobilenativefoundation.store6.core/Freshness.MaxAge.notOlderThan|{}notOlderThan[0] + final fun (): kotlin.time/Duration // org.mobilenativefoundation.store6.core/Freshness.MaxAge.notOlderThan.|(){}[0] + } + + final object CachedOrFetch : org.mobilenativefoundation.store6.core/Freshness { // org.mobilenativefoundation.store6.core/Freshness.CachedOrFetch|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.core/Freshness.CachedOrFetch.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.core/Freshness.CachedOrFetch.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.core/Freshness.CachedOrFetch.toString|toString(){}[0] + } + + final object LocalOnly : org.mobilenativefoundation.store6.core/Freshness { // org.mobilenativefoundation.store6.core/Freshness.LocalOnly|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.core/Freshness.LocalOnly.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.core/Freshness.LocalOnly.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.core/Freshness.LocalOnly.toString|toString(){}[0] + } + + final object MustBeFresh : org.mobilenativefoundation.store6.core/Freshness { // org.mobilenativefoundation.store6.core/Freshness.MustBeFresh|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.core/Freshness.MustBeFresh.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.core/Freshness.MustBeFresh.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.core/Freshness.MustBeFresh.toString|toString(){}[0] + } + + final object StaleIfError : org.mobilenativefoundation.store6.core/Freshness { // org.mobilenativefoundation.store6.core/Freshness.StaleIfError|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.mobilenativefoundation.store6.core/Freshness.StaleIfError.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.mobilenativefoundation.store6.core/Freshness.StaleIfError.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.mobilenativefoundation.store6.core/Freshness.StaleIfError.toString|toString(){}[0] + } +} + +abstract class org.mobilenativefoundation.store6.core.seam/KeyEvents { // org.mobilenativefoundation.store6.core.seam/KeyEvents|null[0] + abstract val key // org.mobilenativefoundation.store6.core.seam/KeyEvents.key|{}key[0] + abstract fun (): org.mobilenativefoundation.store6.core/StoreKey // org.mobilenativefoundation.store6.core.seam/KeyEvents.key.|(){}[0] + + final class Deleted : org.mobilenativefoundation.store6.core.seam/KeyEvents { // org.mobilenativefoundation.store6.core.seam/KeyEvents.Deleted|null[0] + final val key // org.mobilenativefoundation.store6.core.seam/KeyEvents.Deleted.key|{}key[0] + final fun (): org.mobilenativefoundation.store6.core/StoreKey // org.mobilenativefoundation.store6.core.seam/KeyEvents.Deleted.key.|(){}[0] + } + + final class Invalidated : org.mobilenativefoundation.store6.core.seam/KeyEvents { // org.mobilenativefoundation.store6.core.seam/KeyEvents.Invalidated|null[0] + final val key // org.mobilenativefoundation.store6.core.seam/KeyEvents.Invalidated.key|{}key[0] + final fun (): org.mobilenativefoundation.store6.core/StoreKey // org.mobilenativefoundation.store6.core.seam/KeyEvents.Invalidated.key.|(){}[0] + } + + final class Written : org.mobilenativefoundation.store6.core.seam/KeyEvents { // org.mobilenativefoundation.store6.core.seam/KeyEvents.Written|null[0] + final val key // org.mobilenativefoundation.store6.core.seam/KeyEvents.Written.key|{}key[0] + final fun (): org.mobilenativefoundation.store6.core/StoreKey // org.mobilenativefoundation.store6.core.seam/KeyEvents.Written.key.|(){}[0] + final val origin // org.mobilenativefoundation.store6.core.seam/KeyEvents.Written.origin|{}origin[0] + final fun (): org.mobilenativefoundation.store6.core/Origin // org.mobilenativefoundation.store6.core.seam/KeyEvents.Written.origin.|(){}[0] + } +} + +final class <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core/StoreBuilder { // org.mobilenativefoundation.store6.core/StoreBuilder|null[0] + final fun bookkeeper(org.mobilenativefoundation.store6.core.seam/Bookkeeper) // org.mobilenativefoundation.store6.core/StoreBuilder.bookkeeper|bookkeeper(org.mobilenativefoundation.store6.core.seam.Bookkeeper){}[0] + final fun fetcher(kotlin.coroutines/SuspendFunction1<#A, #B>) // org.mobilenativefoundation.store6.core/StoreBuilder.fetcher|fetcher(kotlin.coroutines.SuspendFunction1<1:0,1:1>){}[0] + final fun fetcher(org.mobilenativefoundation.store6.core.seam/Fetcher<#A, #B>) // org.mobilenativefoundation.store6.core/StoreBuilder.fetcher|fetcher(org.mobilenativefoundation.store6.core.seam.Fetcher<1:0,1:1>){}[0] + final fun fetcherOfResult(kotlin.coroutines/SuspendFunction1<#A, org.mobilenativefoundation.store6.core.seam/FetcherResult<#B>>) // org.mobilenativefoundation.store6.core/StoreBuilder.fetcherOfResult|fetcherOfResult(kotlin.coroutines.SuspendFunction1<1:0,org.mobilenativefoundation.store6.core.seam.FetcherResult<1:1>>){}[0] + final fun freshnessValidator(org.mobilenativefoundation.store6.core.seam/FreshnessValidator) // org.mobilenativefoundation.store6.core/StoreBuilder.freshnessValidator|freshnessValidator(org.mobilenativefoundation.store6.core.seam.FreshnessValidator){}[0] + final fun maxIdleKeys(kotlin/Int) // org.mobilenativefoundation.store6.core/StoreBuilder.maxIdleKeys|maxIdleKeys(kotlin.Int){}[0] + final fun overlay(org.mobilenativefoundation.store6.core.seam/Overlay<#A, #B>) // org.mobilenativefoundation.store6.core/StoreBuilder.overlay|overlay(org.mobilenativefoundation.store6.core.seam.Overlay<1:0,1:1>){}[0] + final fun persistence(org.mobilenativefoundation.store6.core.seam/SourceOfTruth<#A, #B>) // org.mobilenativefoundation.store6.core/StoreBuilder.persistence|persistence(org.mobilenativefoundation.store6.core.seam.SourceOfTruth<1:0,1:1>){}[0] + final fun telemetry(org.mobilenativefoundation.store6.core.seam/StoreTelemetry) // org.mobilenativefoundation.store6.core/StoreBuilder.telemetry|telemetry(org.mobilenativefoundation.store6.core.seam.StoreTelemetry){}[0] + final fun wallClock(org.mobilenativefoundation.store6.core.seam/WallClock) // org.mobilenativefoundation.store6.core/StoreBuilder.wallClock|wallClock(org.mobilenativefoundation.store6.core.seam.WallClock){}[0] +} + +final class org.mobilenativefoundation.store6.core.seam/FreshnessContext { // org.mobilenativefoundation.store6.core.seam/FreshnessContext|null[0] + constructor (kotlin/Boolean, org.mobilenativefoundation.store6.core/StoreMeta?, kotlin/Boolean, org.mobilenativefoundation.store6.core/Freshness, kotlin/Long, org.mobilenativefoundation.store6.core.seam/KeyStatus? = ...) // org.mobilenativefoundation.store6.core.seam/FreshnessContext.|(kotlin.Boolean;org.mobilenativefoundation.store6.core.StoreMeta?;kotlin.Boolean;org.mobilenativefoundation.store6.core.Freshness;kotlin.Long;org.mobilenativefoundation.store6.core.seam.KeyStatus?){}[0] + + final val epochStale // org.mobilenativefoundation.store6.core.seam/FreshnessContext.epochStale|{}epochStale[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core.seam/FreshnessContext.epochStale.|(){}[0] + final val freshness // org.mobilenativefoundation.store6.core.seam/FreshnessContext.freshness|{}freshness[0] + final fun (): org.mobilenativefoundation.store6.core/Freshness // org.mobilenativefoundation.store6.core.seam/FreshnessContext.freshness.|(){}[0] + final val hasResidentValue // org.mobilenativefoundation.store6.core.seam/FreshnessContext.hasResidentValue|{}hasResidentValue[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core.seam/FreshnessContext.hasResidentValue.|(){}[0] + final val meta // org.mobilenativefoundation.store6.core.seam/FreshnessContext.meta|{}meta[0] + final fun (): org.mobilenativefoundation.store6.core/StoreMeta? // org.mobilenativefoundation.store6.core.seam/FreshnessContext.meta.|(){}[0] + final val nowEpochMillis // org.mobilenativefoundation.store6.core.seam/FreshnessContext.nowEpochMillis|{}nowEpochMillis[0] + final fun (): kotlin/Long // org.mobilenativefoundation.store6.core.seam/FreshnessContext.nowEpochMillis.|(){}[0] + final val status // org.mobilenativefoundation.store6.core.seam/FreshnessContext.status|{}status[0] + final fun (): org.mobilenativefoundation.store6.core.seam/KeyStatus? // org.mobilenativefoundation.store6.core.seam/FreshnessContext.status.|(){}[0] +} + +final class org.mobilenativefoundation.store6.core.seam/KeyStatus { // org.mobilenativefoundation.store6.core.seam/KeyStatus|null[0] + constructor (org.mobilenativefoundation.store6.core/StoreMeta?, kotlin/Long?, kotlin/Long?, kotlin/Int, kotlin/Boolean) // org.mobilenativefoundation.store6.core.seam/KeyStatus.|(org.mobilenativefoundation.store6.core.StoreMeta?;kotlin.Long?;kotlin.Long?;kotlin.Int;kotlin.Boolean){}[0] + + final val consecutiveFailures // org.mobilenativefoundation.store6.core.seam/KeyStatus.consecutiveFailures|{}consecutiveFailures[0] + final fun (): kotlin/Int // org.mobilenativefoundation.store6.core.seam/KeyStatus.consecutiveFailures.|(){}[0] + final val durablyStale // org.mobilenativefoundation.store6.core.seam/KeyStatus.durablyStale|{}durablyStale[0] + final fun (): kotlin/Boolean // org.mobilenativefoundation.store6.core.seam/KeyStatus.durablyStale.|(){}[0] + final val lastFailureAtEpochMillis // org.mobilenativefoundation.store6.core.seam/KeyStatus.lastFailureAtEpochMillis|{}lastFailureAtEpochMillis[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.core.seam/KeyStatus.lastFailureAtEpochMillis.|(){}[0] + final val lastSuccessSequence // org.mobilenativefoundation.store6.core.seam/KeyStatus.lastSuccessSequence|{}lastSuccessSequence[0] + final fun (): kotlin/Long? // org.mobilenativefoundation.store6.core.seam/KeyStatus.lastSuccessSequence.|(){}[0] + final val meta // org.mobilenativefoundation.store6.core.seam/KeyStatus.meta|{}meta[0] + final fun (): org.mobilenativefoundation.store6.core/StoreMeta? // org.mobilenativefoundation.store6.core.seam/KeyStatus.meta.|(){}[0] +} + +final class org.mobilenativefoundation.store6.core/StoreException : kotlin/RuntimeException { // org.mobilenativefoundation.store6.core/StoreException|null[0] + final val error // org.mobilenativefoundation.store6.core/StoreException.error|{}error[0] + final fun (): org.mobilenativefoundation.store6.core/StoreError // org.mobilenativefoundation.store6.core/StoreException.error.|(){}[0] +} + +final class org.mobilenativefoundation.store6.core/StoreNamespace { // org.mobilenativefoundation.store6.core/StoreNamespace|null[0] + constructor (kotlin/String) // org.mobilenativefoundation.store6.core/StoreNamespace.|(kotlin.String){}[0] + + final val value // org.mobilenativefoundation.store6.core/StoreNamespace.value|{}value[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core/StoreNamespace.value.|(){}[0] +} + +sealed class org.mobilenativefoundation.store6.core/StoreError { // org.mobilenativefoundation.store6.core/StoreError|null[0] + final class Conflict : org.mobilenativefoundation.store6.core/StoreError { // org.mobilenativefoundation.store6.core/StoreError.Conflict|null[0] + final val message // org.mobilenativefoundation.store6.core/StoreError.Conflict.message|{}message[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core/StoreError.Conflict.message.|(){}[0] + final val serverMeta // org.mobilenativefoundation.store6.core/StoreError.Conflict.serverMeta|{}serverMeta[0] + final fun (): org.mobilenativefoundation.store6.core/StoreMeta? // org.mobilenativefoundation.store6.core/StoreError.Conflict.serverMeta.|(){}[0] + } + + final class Conversion : org.mobilenativefoundation.store6.core/StoreError { // org.mobilenativefoundation.store6.core/StoreError.Conversion|null[0] + final val cause // org.mobilenativefoundation.store6.core/StoreError.Conversion.cause|{}cause[0] + final fun (): kotlin/Throwable? // org.mobilenativefoundation.store6.core/StoreError.Conversion.cause.|(){}[0] + final val message // org.mobilenativefoundation.store6.core/StoreError.Conversion.message|{}message[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core/StoreError.Conversion.message.|(){}[0] + } + + final class Fetch : org.mobilenativefoundation.store6.core/StoreError { // org.mobilenativefoundation.store6.core/StoreError.Fetch|null[0] + final val cause // org.mobilenativefoundation.store6.core/StoreError.Fetch.cause|{}cause[0] + final fun (): kotlin/Throwable? // org.mobilenativefoundation.store6.core/StoreError.Fetch.cause.|(){}[0] + final val message // org.mobilenativefoundation.store6.core/StoreError.Fetch.message|{}message[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core/StoreError.Fetch.message.|(){}[0] + } + + final class FreshnessUnsatisfiable : org.mobilenativefoundation.store6.core/StoreError { // org.mobilenativefoundation.store6.core/StoreError.FreshnessUnsatisfiable|null[0] + final val message // org.mobilenativefoundation.store6.core/StoreError.FreshnessUnsatisfiable.message|{}message[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core/StoreError.FreshnessUnsatisfiable.message.|(){}[0] + } + + final class Missing : org.mobilenativefoundation.store6.core/StoreError { // org.mobilenativefoundation.store6.core/StoreError.Missing|null[0] + final val key // org.mobilenativefoundation.store6.core/StoreError.Missing.key|{}key[0] + final fun (): org.mobilenativefoundation.store6.core/StoreKey // org.mobilenativefoundation.store6.core/StoreError.Missing.key.|(){}[0] + final val message // org.mobilenativefoundation.store6.core/StoreError.Missing.message|{}message[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core/StoreError.Missing.message.|(){}[0] + } + + final class Persistence : org.mobilenativefoundation.store6.core/StoreError { // org.mobilenativefoundation.store6.core/StoreError.Persistence|null[0] + final val cause // org.mobilenativefoundation.store6.core/StoreError.Persistence.cause|{}cause[0] + final fun (): kotlin/Throwable? // org.mobilenativefoundation.store6.core/StoreError.Persistence.cause.|(){}[0] + final val message // org.mobilenativefoundation.store6.core/StoreError.Persistence.message|{}message[0] + final fun (): kotlin/String // org.mobilenativefoundation.store6.core/StoreError.Persistence.message.|(){}[0] + } +} + +final object org.mobilenativefoundation.store6.core.seam/StoreResults { // org.mobilenativefoundation.store6.core.seam/StoreResults|null[0] + final fun <#A1: kotlin/Any?> data(#A1, org.mobilenativefoundation.store6.core/Origin, kotlin.time/Duration, kotlin/Boolean, kotlin/Boolean): org.mobilenativefoundation.store6.core/StoreResult.Data<#A1> // org.mobilenativefoundation.store6.core.seam/StoreResults.data|data(0:0;org.mobilenativefoundation.store6.core.Origin;kotlin.time.Duration;kotlin.Boolean;kotlin.Boolean){0§}[0] + final fun conflict(org.mobilenativefoundation.store6.core/StoreMeta?, kotlin/String): org.mobilenativefoundation.store6.core/StoreError.Conflict // org.mobilenativefoundation.store6.core.seam/StoreResults.conflict|conflict(org.mobilenativefoundation.store6.core.StoreMeta?;kotlin.String){}[0] + final fun conversionError(kotlin/String, kotlin/Throwable? = ...): org.mobilenativefoundation.store6.core/StoreError.Conversion // org.mobilenativefoundation.store6.core.seam/StoreResults.conversionError|conversionError(kotlin.String;kotlin.Throwable?){}[0] + final fun error(org.mobilenativefoundation.store6.core/StoreError, kotlin/Boolean): org.mobilenativefoundation.store6.core/StoreResult.Error // org.mobilenativefoundation.store6.core.seam/StoreResults.error|error(org.mobilenativefoundation.store6.core.StoreError;kotlin.Boolean){}[0] + final fun exception(org.mobilenativefoundation.store6.core/StoreError, kotlin/Throwable? = ...): org.mobilenativefoundation.store6.core/StoreException // org.mobilenativefoundation.store6.core.seam/StoreResults.exception|exception(org.mobilenativefoundation.store6.core.StoreError;kotlin.Throwable?){}[0] + final fun fetchError(kotlin/String, kotlin/Throwable? = ...): org.mobilenativefoundation.store6.core/StoreError.Fetch // org.mobilenativefoundation.store6.core.seam/StoreResults.fetchError|fetchError(kotlin.String;kotlin.Throwable?){}[0] + final fun freshnessUnsatisfiable(kotlin/String): org.mobilenativefoundation.store6.core/StoreError.FreshnessUnsatisfiable // org.mobilenativefoundation.store6.core.seam/StoreResults.freshnessUnsatisfiable|freshnessUnsatisfiable(kotlin.String){}[0] + final fun loading(): org.mobilenativefoundation.store6.core/StoreResult.Loading // org.mobilenativefoundation.store6.core.seam/StoreResults.loading|loading(){}[0] + final fun missing(org.mobilenativefoundation.store6.core/StoreKey, kotlin/String): org.mobilenativefoundation.store6.core/StoreError.Missing // org.mobilenativefoundation.store6.core.seam/StoreResults.missing|missing(org.mobilenativefoundation.store6.core.StoreKey;kotlin.String){}[0] + final fun persistenceError(kotlin/String, kotlin/Throwable? = ...): org.mobilenativefoundation.store6.core/StoreError.Persistence // org.mobilenativefoundation.store6.core.seam/StoreResults.persistenceError|persistenceError(kotlin.String;kotlin.Throwable?){}[0] + final fun revalidated(kotlin.time/Duration): org.mobilenativefoundation.store6.core/StoreResult.Revalidated // org.mobilenativefoundation.store6.core.seam/StoreResults.revalidated|revalidated(kotlin.time.Duration){}[0] +} + +final fun <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> (org.mobilenativefoundation.store6.core/Store<#A, #B>).org.mobilenativefoundation.store6.core.seam/runtime(): org.mobilenativefoundation.store6.core.seam/StoreRuntime<#A, #B>? // org.mobilenativefoundation.store6.core.seam/runtime|runtime@org.mobilenativefoundation.store6.core.Store<0:0,0:1>(){0§;1§}[0] +final fun <#A: org.mobilenativefoundation.store6.core/StoreKey, #B: kotlin/Any> org.mobilenativefoundation.store6.core/store(kotlin/Function1, kotlin/Unit>): org.mobilenativefoundation.store6.core/Store<#A, #B> // org.mobilenativefoundation.store6.core/store|store(kotlin.Function1,kotlin.Unit>){0§;1§}[0] diff --git a/store6-core/api/swift/objc/Store6Core.h b/store6-core/api/swift/objc/Store6Core.h new file mode 100644 index 000000000..39dfa6d32 --- /dev/null +++ b/store6-core/api/swift/objc/Store6Core.h @@ -0,0 +1,1005 @@ +#import +#import +#import +#import +#import +#import +#import + +@class Store6CoreFetchPlanSkip, Store6CoreFetcherResultDeleted, Store6CoreFreshnessCachedOrFetch, Store6CoreFreshnessContext, Store6CoreFreshnessLocalOnly, Store6CoreFreshnessMustBeFresh, Store6CoreFreshnessStaleIfError, Store6CoreKeyEvents, Store6CoreKeyStatus, Store6CoreKotlinArray, Store6CoreKotlinEnum, Store6CoreKotlinEnumCompanion, Store6CoreKotlinException, Store6CoreKotlinIllegalStateException, Store6CoreKotlinRuntimeException, Store6CoreKotlinThrowable, Store6CoreOrigin, Store6CoreStoreBuilder, Store6CoreStoreError, Store6CoreStoreErrorConflict, Store6CoreStoreErrorConversion, Store6CoreStoreErrorFetch, Store6CoreStoreErrorFreshnessUnsatisfiable, Store6CoreStoreErrorMissing, Store6CoreStoreErrorPersistence, Store6CoreStoreException, Store6CoreStoreNamespace, Store6CoreStoreResultData, Store6CoreStoreResultError, Store6CoreStoreResultLoading, Store6CoreStoreResultRevalidated, Store6CoreStoreResults; + +@protocol Store6CoreBookkeeper, Store6CoreFetchPlan, Store6CoreFetcher, Store6CoreFetcherResult, Store6CoreFreshness, Store6CoreFreshnessValidator, Store6CoreKotlinComparable, Store6CoreKotlinFunction, Store6CoreKotlinIterator, Store6CoreKotlinSuspendFunction0, Store6CoreKotlinSuspendFunction1, Store6CoreKotlinx_coroutines_coreFlow, Store6CoreKotlinx_coroutines_coreFlowCollector, Store6CoreOverlay, Store6CoreSourceOfTruth, Store6CoreStore, Store6CoreStoreKey, Store6CoreStoreMeta, Store6CoreStoreResult, Store6CoreStoreRuntime, Store6CoreStoreTelemetry, Store6CoreStoreWriteHandle, Store6CoreWallClock; + +NS_ASSUME_NONNULL_BEGIN +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunknown-warning-option" +#pragma clang diagnostic ignored "-Wincompatible-property-type" +#pragma clang diagnostic ignored "-Wnullability" + +#pragma push_macro("_Nullable_result") +#if !__has_feature(nullability_nullable_result) +#undef _Nullable_result +#define _Nullable_result _Nullable +#endif + +__attribute__((swift_name("KotlinBase"))) +@interface Store6CoreBase : NSObject +- (instancetype)init __attribute__((unavailable)); ++ (instancetype)new __attribute__((unavailable)); ++ (void)initialize __attribute__((objc_requires_super)); +@end + +@interface Store6CoreBase (Store6CoreBaseCopying) +@end + +__attribute__((swift_name("KotlinMutableSet"))) +@interface Store6CoreMutableSet : NSMutableSet +@end + +__attribute__((swift_name("KotlinMutableDictionary"))) +@interface Store6CoreMutableDictionary : NSMutableDictionary +@end + +@interface NSError (NSErrorStore6CoreKotlinException) +@property (readonly) id _Nullable kotlinException; +@end + +__attribute__((swift_name("KotlinNumber"))) +@interface Store6CoreNumber : NSNumber +- (instancetype)initWithChar:(char)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedChar:(unsigned char)value __attribute__((unavailable)); +- (instancetype)initWithShort:(short)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedShort:(unsigned short)value __attribute__((unavailable)); +- (instancetype)initWithInt:(int)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedInt:(unsigned int)value __attribute__((unavailable)); +- (instancetype)initWithLong:(long)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedLong:(unsigned long)value __attribute__((unavailable)); +- (instancetype)initWithLongLong:(long long)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable)); +- (instancetype)initWithFloat:(float)value __attribute__((unavailable)); +- (instancetype)initWithDouble:(double)value __attribute__((unavailable)); +- (instancetype)initWithBool:(BOOL)value __attribute__((unavailable)); +- (instancetype)initWithInteger:(NSInteger)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable)); ++ (instancetype)numberWithChar:(char)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedChar:(unsigned char)value __attribute__((unavailable)); ++ (instancetype)numberWithShort:(short)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedShort:(unsigned short)value __attribute__((unavailable)); ++ (instancetype)numberWithInt:(int)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedInt:(unsigned int)value __attribute__((unavailable)); ++ (instancetype)numberWithLong:(long)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedLong:(unsigned long)value __attribute__((unavailable)); ++ (instancetype)numberWithLongLong:(long long)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable)); ++ (instancetype)numberWithFloat:(float)value __attribute__((unavailable)); ++ (instancetype)numberWithDouble:(double)value __attribute__((unavailable)); ++ (instancetype)numberWithBool:(BOOL)value __attribute__((unavailable)); ++ (instancetype)numberWithInteger:(NSInteger)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable)); +@end + +__attribute__((swift_name("KotlinByte"))) +@interface Store6CoreByte : Store6CoreNumber +- (instancetype)initWithChar:(char)value; ++ (instancetype)numberWithChar:(char)value; +@end + +__attribute__((swift_name("KotlinUByte"))) +@interface Store6CoreUByte : Store6CoreNumber +- (instancetype)initWithUnsignedChar:(unsigned char)value; ++ (instancetype)numberWithUnsignedChar:(unsigned char)value; +@end + +__attribute__((swift_name("KotlinShort"))) +@interface Store6CoreShort : Store6CoreNumber +- (instancetype)initWithShort:(short)value; ++ (instancetype)numberWithShort:(short)value; +@end + +__attribute__((swift_name("KotlinUShort"))) +@interface Store6CoreUShort : Store6CoreNumber +- (instancetype)initWithUnsignedShort:(unsigned short)value; ++ (instancetype)numberWithUnsignedShort:(unsigned short)value; +@end + +__attribute__((swift_name("KotlinInt"))) +@interface Store6CoreInt : Store6CoreNumber +- (instancetype)initWithInt:(int)value; ++ (instancetype)numberWithInt:(int)value; +@end + +__attribute__((swift_name("KotlinUInt"))) +@interface Store6CoreUInt : Store6CoreNumber +- (instancetype)initWithUnsignedInt:(unsigned int)value; ++ (instancetype)numberWithUnsignedInt:(unsigned int)value; +@end + +__attribute__((swift_name("KotlinLong"))) +@interface Store6CoreLong : Store6CoreNumber +- (instancetype)initWithLongLong:(long long)value; ++ (instancetype)numberWithLongLong:(long long)value; +@end + +__attribute__((swift_name("KotlinULong"))) +@interface Store6CoreULong : Store6CoreNumber +- (instancetype)initWithUnsignedLongLong:(unsigned long long)value; ++ (instancetype)numberWithUnsignedLongLong:(unsigned long long)value; +@end + +__attribute__((swift_name("KotlinFloat"))) +@interface Store6CoreFloat : Store6CoreNumber +- (instancetype)initWithFloat:(float)value; ++ (instancetype)numberWithFloat:(float)value; +@end + +__attribute__((swift_name("KotlinDouble"))) +@interface Store6CoreDouble : Store6CoreNumber +- (instancetype)initWithDouble:(double)value; ++ (instancetype)numberWithDouble:(double)value; +@end + +__attribute__((swift_name("KotlinBoolean"))) +@interface Store6CoreBoolean : Store6CoreNumber +- (instancetype)initWithBool:(BOOL)value; ++ (instancetype)numberWithBool:(BOOL)value; +@end + +__attribute__((swift_name("Freshness"))) +@protocol Store6CoreFreshness +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessCachedOrFetch"))) +@interface Store6CoreFreshnessCachedOrFetch : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)cachedOrFetch __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreFreshnessCachedOrFetch *shared __attribute__((swift_name("shared"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessLocalOnly"))) +@interface Store6CoreFreshnessLocalOnly : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)localOnly __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreFreshnessLocalOnly *shared __attribute__((swift_name("shared"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessMaxAge"))) +@interface Store6CoreFreshnessMaxAge : Store6CoreBase +- (instancetype)initWithNotOlderThan:(int64_t)notOlderThan __attribute__((swift_name("init(notOlderThan:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) int64_t notOlderThan __attribute__((swift_name("notOlderThan"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessMustBeFresh"))) +@interface Store6CoreFreshnessMustBeFresh : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)mustBeFresh __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreFreshnessMustBeFresh *shared __attribute__((swift_name("shared"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessStaleIfError"))) +@interface Store6CoreFreshnessStaleIfError : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)staleIfError __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreFreshnessStaleIfError *shared __attribute__((swift_name("shared"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((swift_name("KotlinComparable"))) +@protocol Store6CoreKotlinComparable +@required +- (int32_t)compareToOther:(id _Nullable)other __attribute__((swift_name("compareTo(other:)"))); +@end + +__attribute__((swift_name("KotlinEnum"))) +@interface Store6CoreKotlinEnum : Store6CoreBase +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)); +@property (class, readonly, getter=companion) Store6CoreKotlinEnumCompanion *companion __attribute__((swift_name("companion"))); +- (int32_t)compareToOther:(E)other __attribute__((swift_name("compareTo(other:)"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@property (readonly) NSString *name __attribute__((swift_name("name"))); +@property (readonly) int32_t ordinal __attribute__((swift_name("ordinal"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Origin"))) +@interface Store6CoreOrigin : Store6CoreKotlinEnum ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +@property (class, readonly) Store6CoreOrigin *memory __attribute__((swift_name("memory"))); +@property (class, readonly) Store6CoreOrigin *sot __attribute__((swift_name("sot"))); +@property (class, readonly) Store6CoreOrigin *fetcher __attribute__((swift_name("fetcher"))); +@property (class, readonly) Store6CoreOrigin *overlay __attribute__((swift_name("overlay"))); ++ (Store6CoreKotlinArray *)values __attribute__((swift_name("values()"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); +@end + + +/** + * @note annotations + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store"))) +@protocol Store6CoreStore +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clear(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearNamespaceNamespace:(Store6CoreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearNamespace(namespace:completionHandler:)"))); +- (void)close __attribute__((swift_name("close()"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)getKey:(id)key freshness:(id)freshness completionHandler:(void (^)(id _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("get(key:freshness:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidate(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateNamespaceNamespace:(Store6CoreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateNamespace(namespace:completionHandler:)"))); +- (id)streamKey:(id)key freshness:(id)freshness __attribute__((swift_name("stream(key:freshness:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreBuilder"))) +@interface Store6CoreStoreBuilder : Store6CoreBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)bookkeeperBookkeeper:(id)bookkeeper __attribute__((swift_name("bookkeeper(bookkeeper:)"))); +- (void)fetcherFetch:(id)fetch __attribute__((swift_name("fetcher(fetch:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)fetcherFetcher:(id)fetcher __attribute__((swift_name("fetcher(fetcher:)"))); +- (void)fetcherOfResultFetch:(id)fetch __attribute__((swift_name("fetcherOfResult(fetch:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)freshnessValidatorValidator:(id)validator __attribute__((swift_name("freshnessValidator(validator:)"))); +- (void)maxIdleKeysCount:(int32_t)count __attribute__((swift_name("maxIdleKeys(count:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)overlayOverlay:(id)overlay __attribute__((swift_name("overlay(overlay:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)persistenceSot:(id)sot __attribute__((swift_name("persistence(sot:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)telemetryTelemetry:(id)telemetry __attribute__((swift_name("telemetry(telemetry:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)wallClockWallClock:(id)wallClock __attribute__((swift_name("wallClock(wallClock:)"))); +@end + +__attribute__((swift_name("StoreError"))) +@interface Store6CoreStoreError : Store6CoreBase +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Conflict"))) +@interface Store6CoreStoreErrorConflict : Store6CoreStoreError +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@property (readonly) id _Nullable serverMeta __attribute__((swift_name("serverMeta"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Conversion"))) +@interface Store6CoreStoreErrorConversion : Store6CoreStoreError +@property (readonly) Store6CoreKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Fetch"))) +@interface Store6CoreStoreErrorFetch : Store6CoreStoreError +@property (readonly) Store6CoreKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.FreshnessUnsatisfiable"))) +@interface Store6CoreStoreErrorFreshnessUnsatisfiable : Store6CoreStoreError +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Missing"))) +@interface Store6CoreStoreErrorMissing : Store6CoreStoreError +@property (readonly) id key __attribute__((swift_name("key"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Persistence"))) +@interface Store6CoreStoreErrorPersistence : Store6CoreStoreError +@property (readonly) Store6CoreKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((swift_name("KotlinThrowable"))) +@interface Store6CoreKotlinThrowable : Store6CoreBase +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * kotlin.experimental.ExperimentalNativeApi +*/ +- (Store6CoreKotlinArray *)getStackTrace __attribute__((swift_name("getStackTrace()"))); +- (void)printStackTrace __attribute__((swift_name("printStackTrace()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@property (readonly) Store6CoreKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString * _Nullable message __attribute__((swift_name("message"))); +- (NSError *)asError __attribute__((swift_name("asError()"))); +@end + +__attribute__((swift_name("KotlinException"))) +@interface Store6CoreKotlinException : Store6CoreKotlinThrowable +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("KotlinRuntimeException"))) +@interface Store6CoreKotlinRuntimeException : Store6CoreKotlinException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreException"))) +@interface Store6CoreStoreException : Store6CoreKotlinRuntimeException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); ++ (instancetype)new __attribute__((unavailable)); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +- (instancetype)initWithCause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +@property (readonly) Store6CoreStoreError *error __attribute__((swift_name("error"))); +@end + +__attribute__((swift_name("StoreKey"))) +@protocol Store6CoreStoreKey +@required +- (NSString *)canonicalId __attribute__((swift_name("canonicalId()"))); +@property (readonly, getter=namespace) Store6CoreStoreNamespace *namespace_ __attribute__((swift_name("namespace_"))); +@end + +__attribute__((swift_name("StoreMeta"))) +@protocol Store6CoreStoreMeta +@required +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@property (readonly) int64_t writtenAtEpochMillis __attribute__((swift_name("writtenAtEpochMillis"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreNamespace"))) +@interface Store6CoreStoreNamespace : Store6CoreBase +- (instancetype)initWithValue:(NSString *)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) NSString *value __attribute__((swift_name("value"))); +@end + +__attribute__((swift_name("StoreResult"))) +@protocol Store6CoreStoreResult +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultData"))) +@interface Store6CoreStoreResultData : Store6CoreBase +@property (readonly) int64_t age __attribute__((swift_name("age"))); +@property (readonly) BOOL isStale __attribute__((swift_name("isStale"))); +@property (readonly) Store6CoreOrigin *origin __attribute__((swift_name("origin"))); +@property (readonly) BOOL refreshing __attribute__((swift_name("refreshing"))); +@property (readonly) V _Nullable value __attribute__((swift_name("value"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultError"))) +@interface Store6CoreStoreResultError : Store6CoreBase +@property (readonly) Store6CoreStoreError *error __attribute__((swift_name("error"))); +@property (readonly) BOOL servedStale __attribute__((swift_name("servedStale"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultLoading"))) +@interface Store6CoreStoreResultLoading : Store6CoreBase +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultRevalidated"))) +@interface Store6CoreStoreResultRevalidated : Store6CoreBase +@property (readonly) int64_t age __attribute__((swift_name("age"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Bookkeeper"))) +@protocol Store6CoreBookkeeper +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)advanceGlobalStaleWatermarkWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("advanceGlobalStaleWatermark(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)advanceStaleWatermarkNamespace:(Store6CoreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("advanceStaleWatermark(namespace:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forget(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forgetAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetNamespaceNamespace:(Store6CoreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forgetNamespace(namespace:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)markStaleKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("markStale(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)recordFailureKey:(id)key atEpochMillis:(int64_t)atEpochMillis completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("recordFailure(key:atEpochMillis:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)recordSuccessKey:(id)key meta:(id)meta completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("recordSuccess(key:meta:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)statusKey:(id)key completionHandler:(void (^)(Store6CoreKeyStatus * _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("status(key:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("FetchPlan"))) +@protocol Store6CoreFetchPlan +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetchPlanConditional"))) +@interface Store6CoreFetchPlanConditional : Store6CoreBase +- (instancetype)initWithEtag:(NSString *)etag servesResidentWhileFetching:(BOOL)servesResidentWhileFetching __attribute__((swift_name("init(etag:servesResidentWhileFetching:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) NSString *etag __attribute__((swift_name("etag"))); +@property (readonly) BOOL servesResidentWhileFetching __attribute__((swift_name("servesResidentWhileFetching"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetchPlanFetch"))) +@interface Store6CoreFetchPlanFetch : Store6CoreBase +- (instancetype)initWithServesResidentWhileFetching:(BOOL)servesResidentWhileFetching __attribute__((swift_name("init(servesResidentWhileFetching:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) BOOL servesResidentWhileFetching __attribute__((swift_name("servesResidentWhileFetching"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetchPlanSkip"))) +@interface Store6CoreFetchPlanSkip : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)skip __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreFetchPlanSkip *shared __attribute__((swift_name("shared"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Fetcher"))) +@protocol Store6CoreFetcher +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)fetchKey:(id)key etag:(NSString * _Nullable)etag completionHandler:(void (^)(id _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("fetch(key:etag:completionHandler:)"))); +@end + +__attribute__((swift_name("FetcherResult"))) +@protocol Store6CoreFetcherResult +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultDeleted"))) +@interface Store6CoreFetcherResultDeleted : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)deleted __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreFetcherResultDeleted *shared __attribute__((swift_name("shared"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultError"))) +@interface Store6CoreFetcherResultError : Store6CoreBase +- (instancetype)initWithCause:(Store6CoreKotlinThrowable *)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) Store6CoreKotlinThrowable *cause __attribute__((swift_name("cause"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultNotModified"))) +@interface Store6CoreFetcherResultNotModified : Store6CoreBase +- (instancetype)initWithEtag:(NSString * _Nullable)etag __attribute__((swift_name("init(etag:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultSuccess"))) +@interface Store6CoreFetcherResultSuccess : Store6CoreBase +- (instancetype)initWithValue:(V)value etag:(NSString * _Nullable)etag __attribute__((swift_name("init(value:etag:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@property (readonly) V value __attribute__((swift_name("value"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessContext"))) +@interface Store6CoreFreshnessContext : Store6CoreBase +- (instancetype)initWithHasResidentValue:(BOOL)hasResidentValue meta:(id _Nullable)meta epochStale:(BOOL)epochStale freshness:(id)freshness nowEpochMillis:(int64_t)nowEpochMillis status:(Store6CoreKeyStatus * _Nullable)status __attribute__((swift_name("init(hasResidentValue:meta:epochStale:freshness:nowEpochMillis:status:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) BOOL epochStale __attribute__((swift_name("epochStale"))); +@property (readonly) id freshness __attribute__((swift_name("freshness"))); +@property (readonly) BOOL hasResidentValue __attribute__((swift_name("hasResidentValue"))); +@property (readonly) id _Nullable meta __attribute__((swift_name("meta"))); +@property (readonly) int64_t nowEpochMillis __attribute__((swift_name("nowEpochMillis"))); +@property (readonly) Store6CoreKeyStatus * _Nullable status __attribute__((swift_name("status"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("FreshnessValidator"))) +@protocol Store6CoreFreshnessValidator +@required +- (id)planContext:(Store6CoreFreshnessContext *)context __attribute__((swift_name("plan(context:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("KeyEvents"))) +@interface Store6CoreKeyEvents : Store6CoreBase +@property (readonly) id key __attribute__((swift_name("key"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyEvents.Deleted"))) +@interface Store6CoreKeyEventsDeleted : Store6CoreKeyEvents +@property (readonly) id key __attribute__((swift_name("key"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyEvents.Invalidated"))) +@interface Store6CoreKeyEventsInvalidated : Store6CoreKeyEvents +@property (readonly) id key __attribute__((swift_name("key"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyEvents.Written"))) +@interface Store6CoreKeyEventsWritten : Store6CoreKeyEvents +@property (readonly) id key __attribute__((swift_name("key"))); +@property (readonly) Store6CoreOrigin *origin __attribute__((swift_name("origin"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyStatus"))) +@interface Store6CoreKeyStatus : Store6CoreBase +- (instancetype)initWithMeta:(id _Nullable)meta lastSuccessSequence:(Store6CoreLong * _Nullable)lastSuccessSequence lastFailureAtEpochMillis:(Store6CoreLong * _Nullable)lastFailureAtEpochMillis consecutiveFailures:(int32_t)consecutiveFailures durablyStale:(BOOL)durablyStale __attribute__((swift_name("init(meta:lastSuccessSequence:lastFailureAtEpochMillis:consecutiveFailures:durablyStale:)"))) __attribute__((objc_designated_initializer)); +@property (readonly) int32_t consecutiveFailures __attribute__((swift_name("consecutiveFailures"))); +@property (readonly) BOOL durablyStale __attribute__((swift_name("durablyStale"))); +@property (readonly) Store6CoreLong * _Nullable lastFailureAtEpochMillis __attribute__((swift_name("lastFailureAtEpochMillis"))); +@property (readonly) Store6CoreLong * _Nullable lastSuccessSequence __attribute__((swift_name("lastSuccessSequence"))); +@property (readonly) id _Nullable meta __attribute__((swift_name("meta"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Overlay"))) +@protocol Store6CoreOverlay +@required +- (id _Nullable)applyKey:(id)key base:(id _Nullable)base __attribute__((swift_name("apply(key:base:)"))); +@property (readonly) id changes __attribute__((swift_name("changes"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("SourceOfTruth"))) +@protocol Store6CoreSourceOfTruth +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("delete(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("deleteAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteNamespaceNamespace:(Store6CoreStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("deleteNamespace(namespace:completionHandler:)"))); +- (id)readerKey:(id)key __attribute__((swift_name("reader(key:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)writeKey:(id)key value:(id)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("write(key:value:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResults"))) +@interface Store6CoreStoreResults : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)storeResults __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreStoreResults *shared __attribute__((swift_name("shared"))); +- (Store6CoreStoreErrorConflict *)conflictServerMeta:(id _Nullable)serverMeta message:(NSString *)message __attribute__((swift_name("conflict(serverMeta:message:)"))); +- (Store6CoreStoreErrorConversion *)conversionErrorMessage:(NSString *)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("conversionError(message:cause:)"))); +- (Store6CoreStoreResultData *)dataValue:(id _Nullable)value origin:(Store6CoreOrigin *)origin age:(int64_t)age isStale:(BOOL)isStale refreshing:(BOOL)refreshing __attribute__((swift_name("data(value:origin:age:isStale:refreshing:)"))); +- (Store6CoreStoreResultError *)errorError:(Store6CoreStoreError *)error servedStale:(BOOL)servedStale __attribute__((swift_name("error(error:servedStale:)"))); +- (Store6CoreStoreException *)exceptionError:(Store6CoreStoreError *)error cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("exception(error:cause:)"))); +- (Store6CoreStoreErrorFetch *)fetchErrorMessage:(NSString *)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("fetchError(message:cause:)"))); +- (Store6CoreStoreErrorFreshnessUnsatisfiable *)freshnessUnsatisfiableMessage:(NSString *)message __attribute__((swift_name("freshnessUnsatisfiable(message:)"))); +- (Store6CoreStoreResultLoading *)loading __attribute__((swift_name("loading()"))); +- (Store6CoreStoreErrorMissing *)missingKey:(id)key message:(NSString *)message __attribute__((swift_name("missing(key:message:)"))); +- (Store6CoreStoreErrorPersistence *)persistenceErrorMessage:(NSString *)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("persistenceError(message:cause:)"))); +- (Store6CoreStoreResultRevalidated *)revalidatedAge:(int64_t)age __attribute__((swift_name("revalidated(age:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("StoreRuntime"))) +@protocol Store6CoreStoreRuntime +@required +@property (readonly) id keyEvents __attribute__((swift_name("keyEvents"))); +@property (readonly) id _Nullable telemetry __attribute__((swift_name("telemetry"))); +@property (readonly) id writeHandle __attribute__((swift_name("writeHandle"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("StoreTelemetry"))) +@protocol Store6CoreStoreTelemetry +@required +- (void)onClearedKey:(id)key __attribute__((swift_name("onCleared(key:)"))); +- (void)onFetchFailedKey:(id)key error:(Store6CoreStoreError *)error duration:(int64_t)duration __attribute__((swift_name("onFetchFailed(key:error:duration:)"))); +- (void)onFetchStartedKey:(id)key __attribute__((swift_name("onFetchStarted(key:)"))); +- (void)onFetchSucceededKey:(id)key duration:(int64_t)duration __attribute__((swift_name("onFetchSucceeded(key:duration:)"))); +- (void)onInvalidatedKey:(id)key __attribute__((swift_name("onInvalidated(key:)"))); +- (void)onServeKey:(id)key origin:(Store6CoreOrigin *)origin __attribute__((swift_name("onServe(key:origin:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("StoreWriteHandle"))) +@protocol Store6CoreStoreWriteHandle +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)applyKey:(id)key value:(id)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("apply(key:value:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)confirmFreshKey:(id)key etag:(NSString * _Nullable)etag completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("confirmFresh(key:etag:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)markStaleKey:(id)key completionHandler_:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("markStale(key:completionHandler_:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("TransactionalSourceOfTruth"))) +@protocol Store6CoreTransactionalSourceOfTruth +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)withTransactionBlock:(id)block completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("withTransaction(block:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("WallClock"))) +@protocol Store6CoreWallClock +@required +- (int64_t)nowEpochMillis __attribute__((swift_name("nowEpochMillis()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreBuilderKt"))) +@interface Store6CoreStoreBuilderKt : Store6CoreBase ++ (id)storeConfigure:(void (^)(Store6CoreStoreBuilder, id> *))configure __attribute__((swift_name("store(configure:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreRuntimeKt"))) +@interface Store6CoreStoreRuntimeKt : Store6CoreBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ ++ (id _Nullable)runtime:(id)receiver __attribute__((swift_name("runtime(_:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinEnumCompanion"))) +@interface Store6CoreKotlinEnumCompanion : Store6CoreBase ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)companion __attribute__((swift_name("init()"))); +@property (class, readonly, getter=shared) Store6CoreKotlinEnumCompanion *shared __attribute__((swift_name("shared"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinArray"))) +@interface Store6CoreKotlinArray : Store6CoreBase ++ (instancetype)arrayWithSize:(int32_t)size init:(T _Nullable (^)(Store6CoreInt *))init __attribute__((swift_name("init(size:init:)"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (T _Nullable)getIndex:(int32_t)index __attribute__((swift_name("get(index:)"))); +- (id)iterator __attribute__((swift_name("iterator()"))); +- (void)setIndex:(int32_t)index value:(T _Nullable)value __attribute__((swift_name("set(index:value:)"))); +@property (readonly) int32_t size __attribute__((swift_name("size"))); +@end + +__attribute__((swift_name("KotlinIllegalStateException"))) +@interface Store6CoreKotlinIllegalStateException : Store6CoreKotlinRuntimeException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + + +/** + * @note annotations + * kotlin.SinceKotlin(version="1.4") +*/ +__attribute__((swift_name("KotlinCancellationException"))) +@interface Store6CoreKotlinCancellationException : Store6CoreKotlinIllegalStateException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(Store6CoreKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreFlow"))) +@protocol Store6CoreKotlinx_coroutines_coreFlow +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((swift_name("KotlinFunction"))) +@protocol Store6CoreKotlinFunction +@required +@end + +__attribute__((swift_name("KotlinSuspendFunction1"))) +@protocol Store6CoreKotlinSuspendFunction1 +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invokeP1:(id _Nullable)p1 completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("invoke(p1:completionHandler:)"))); +@end + +__attribute__((swift_name("KotlinSuspendFunction0"))) +@protocol Store6CoreKotlinSuspendFunction0 +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invokeWithCompletionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("invoke(completionHandler:)"))); +@end + +__attribute__((swift_name("KotlinIterator"))) +@protocol Store6CoreKotlinIterator +@required +- (BOOL)hasNext __attribute__((swift_name("hasNext()"))); +- (id _Nullable)next __attribute__((swift_name("next()"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreFlowCollector"))) +@protocol Store6CoreKotlinx_coroutines_coreFlowCollector +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(id _Nullable)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); +@end + +#pragma pop_macro("_Nullable_result") +#pragma clang diagnostic pop +NS_ASSUME_NONNULL_END diff --git a/store6-core/api/swift/skie/Store6CoreSkie.h b/store6-core/api/swift/skie/Store6CoreSkie.h new file mode 100644 index 000000000..a6fc0e587 --- /dev/null +++ b/store6-core/api/swift/skie/Store6CoreSkie.h @@ -0,0 +1,1358 @@ +#import +#import +#import +#import +#import +#import +#import + +@class SCS__SkieSuspendWrappersKt, SCSUShort, SCSULong, SCSUInt, SCSUByte, SCSStoreRuntimeKt, SCSStoreResults, SCSStoreResultRevalidated, SCSStoreResultLoading, SCSStoreResultError, SCSStoreResultData, SCSStoreNamespace, SCSStoreException, SCSStoreErrorPersistence, SCSStoreErrorMissing, SCSStoreErrorFreshnessUnsatisfiable, SCSStoreErrorFetch, SCSStoreErrorConversion, SCSStoreErrorConflict, SCSStoreError, SCSStoreBuilderKt, SCSStoreBuilder, SCSSkie_SuspendResultSuccess, SCSSkie_SuspendResultError, SCSSkie_SuspendResultCanceled, SCSSkie_SuspendResult, SCSSkie_SuspendHandler, SCSSkie_CancellationHandler, SCSSkieKotlinStateFlow, SCSSkieKotlinSharedFlow, SCSSkieKotlinOptionalStateFlow, SCSSkieKotlinOptionalSharedFlow, SCSSkieKotlinOptionalMutableStateFlow, SCSSkieKotlinOptionalMutableSharedFlow, SCSSkieKotlinOptionalFlow, SCSSkieKotlinMutableStateFlow, SCSSkieKotlinMutableSharedFlow, SCSSkieKotlinFlow, SCSSkieColdFlowIterator, SCSShort, SCSOrigin, SCSNumber, SCSMutableSet, SCSMutableDictionary, SCSLong, SCSKotlinThrowable, SCSKotlinRuntimeException, SCSKotlinIllegalStateException, SCSKotlinException, SCSKotlinEnumCompanion, SCSKotlinEnum, SCSKotlinCancellationException, SCSKotlinArray, SCSKeyStatus, SCSKeyEventsWritten, SCSKeyEventsInvalidated, SCSKeyEventsDeleted, SCSKeyEvents, SCSInt, SCSFreshnessStaleIfError, SCSFreshnessMustBeFresh, SCSFreshnessMaxAge, SCSFreshnessLocalOnly, SCSFreshnessContext, SCSFreshnessCachedOrFetch, SCSFloat, SCSFetcherResultSuccess, SCSFetcherResultNotModified, SCSFetcherResultError, SCSFetcherResultDeleted, SCSFetchPlanSkip, SCSFetchPlanFetch, SCSFetchPlanConditional, SCSDouble, SCSByte, SCSBoolean, SCSBase, NSString, NSSet, NSObject, NSNumber, NSMutableSet, NSMutableDictionary, NSMutableArray, NSError, NSDictionary, NSArray; + +@protocol SCSWallClock, SCSTransactionalSourceOfTruth, SCSStoreWriteHandle, SCSStoreTelemetry, SCSStoreRuntime, SCSStoreResult, SCSStoreMeta, SCSStoreKey, SCSStore, SCSSourceOfTruth, SCSSkie_DispatcherDelegate, SCSOverlay, SCSKotlinx_coroutines_coreStateFlow, SCSKotlinx_coroutines_coreSharedFlow, SCSKotlinx_coroutines_coreRunnable, SCSKotlinx_coroutines_coreMutableStateFlow, SCSKotlinx_coroutines_coreMutableSharedFlow, SCSKotlinx_coroutines_coreFlowCollector, SCSKotlinx_coroutines_coreFlow, SCSKotlinSuspendFunction1, SCSKotlinSuspendFunction0, SCSKotlinIterator, SCSKotlinFunction, SCSKotlinComparable, SCSFreshnessValidator, SCSFreshness, SCSFetcherResult, SCSFetcher, SCSFetchPlan, SCSBookkeeper, NSCopying; + +// Due to an Obj-C/Swift interop limitation, SKIE cannot generate Swift types with a lambda type argument. +// Example of such type is: A<() -> Unit> where A is a generic class. +// To avoid compilation errors SKIE replaces these type arguments with __SkieLambdaErrorType, resulting in A<__SkieLambdaErrorType>. +// Generated declarations that reference __SkieLambdaErrorType cannot be called in any way and the __SkieLambdaErrorType class cannot be used. +// The original declarations can still be used in the same way as other declarations hidden by SKIE (and with the same limitations as without SKIE). +@interface __SkieLambdaErrorType : NSObject +- (instancetype _Nonnull)init __attribute__((unavailable)); ++ (instancetype _Nonnull)new __attribute__((unavailable)); +@end + +// Due to an Obj-C/Swift interop limitation, SKIE cannot generate Swift code that uses external Obj-C types for which SKIE doesn't know a fully qualified name. +// This problem occurs when custom Cinterop bindings are used because those do not contain the name of the Framework that provides implementation for those binding. +// The name can be configured manually using the SKIE Gradle configuration key 'ClassInterop.CInteropFrameworkName' in the same way as other SKIE features. +// To avoid compilation errors SKIE replaces types with unknown Framework name with __SkieUnknownCInteropFrameworkErrorType. +// Generated declarations that reference __SkieUnknownCInteropFrameworkErrorType cannot be called in any way and the __SkieUnknownCInteropFrameworkErrorType class cannot be used. +@interface __SkieUnknownCInteropFrameworkErrorType : NSObject +- (instancetype _Nonnull)init __attribute__((unavailable)); ++ (instancetype _Nonnull)new __attribute__((unavailable)); +@end + + +NS_ASSUME_NONNULL_BEGIN +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunknown-warning-option" +#pragma clang diagnostic ignored "-Wincompatible-property-type" +#pragma clang diagnostic ignored "-Wnullability" + +#pragma push_macro("_Nullable_result") +#if !__has_feature(nullability_nullable_result) +#undef _Nullable_result +#define _Nullable_result _Nullable +#endif + +__attribute__((swift_name("KotlinBase"))) +@interface SCSBase : NSObject +- (instancetype)init __attribute__((unavailable)); ++ (instancetype)new __attribute__((unavailable)); ++ (void)initialize __attribute__((objc_requires_super)); +@end + +@interface SCSBase (SCSBaseCopying) +@end + +__attribute__((swift_name("KotlinMutableSet"))) +@interface SCSMutableSet : NSMutableSet +@end + +__attribute__((swift_name("KotlinMutableDictionary"))) +@interface SCSMutableDictionary : NSMutableDictionary +@end + +@interface NSError (NSErrorSCSKotlinException) +@property (readonly) id _Nullable kotlinException; +@end + +__attribute__((swift_name("KotlinNumber"))) +@interface SCSNumber : NSNumber +- (instancetype)initWithChar:(char)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedChar:(unsigned char)value __attribute__((unavailable)); +- (instancetype)initWithShort:(short)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedShort:(unsigned short)value __attribute__((unavailable)); +- (instancetype)initWithInt:(int)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedInt:(unsigned int)value __attribute__((unavailable)); +- (instancetype)initWithLong:(long)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedLong:(unsigned long)value __attribute__((unavailable)); +- (instancetype)initWithLongLong:(long long)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable)); +- (instancetype)initWithFloat:(float)value __attribute__((unavailable)); +- (instancetype)initWithDouble:(double)value __attribute__((unavailable)); +- (instancetype)initWithBool:(BOOL)value __attribute__((unavailable)); +- (instancetype)initWithInteger:(NSInteger)value __attribute__((unavailable)); +- (instancetype)initWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable)); ++ (instancetype)numberWithChar:(char)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedChar:(unsigned char)value __attribute__((unavailable)); ++ (instancetype)numberWithShort:(short)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedShort:(unsigned short)value __attribute__((unavailable)); ++ (instancetype)numberWithInt:(int)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedInt:(unsigned int)value __attribute__((unavailable)); ++ (instancetype)numberWithLong:(long)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedLong:(unsigned long)value __attribute__((unavailable)); ++ (instancetype)numberWithLongLong:(long long)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable)); ++ (instancetype)numberWithFloat:(float)value __attribute__((unavailable)); ++ (instancetype)numberWithDouble:(double)value __attribute__((unavailable)); ++ (instancetype)numberWithBool:(BOOL)value __attribute__((unavailable)); ++ (instancetype)numberWithInteger:(NSInteger)value __attribute__((unavailable)); ++ (instancetype)numberWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable)); +@end + +__attribute__((swift_name("KotlinByte"))) +@interface SCSByte : SCSNumber +- (instancetype)initWithChar:(char)value; ++ (instancetype)numberWithChar:(char)value; +@end + +__attribute__((swift_name("KotlinUByte"))) +@interface SCSUByte : SCSNumber +- (instancetype)initWithUnsignedChar:(unsigned char)value; ++ (instancetype)numberWithUnsignedChar:(unsigned char)value; +@end + +__attribute__((swift_name("KotlinShort"))) +@interface SCSShort : SCSNumber +- (instancetype)initWithShort:(short)value; ++ (instancetype)numberWithShort:(short)value; +@end + +__attribute__((swift_name("KotlinUShort"))) +@interface SCSUShort : SCSNumber +- (instancetype)initWithUnsignedShort:(unsigned short)value; ++ (instancetype)numberWithUnsignedShort:(unsigned short)value; +@end + +__attribute__((swift_name("KotlinInt"))) +@interface SCSInt : SCSNumber +- (instancetype)initWithInt:(int)value; ++ (instancetype)numberWithInt:(int)value; +@end + +__attribute__((swift_name("KotlinUInt"))) +@interface SCSUInt : SCSNumber +- (instancetype)initWithUnsignedInt:(unsigned int)value; ++ (instancetype)numberWithUnsignedInt:(unsigned int)value; +@end + +__attribute__((swift_name("KotlinLong"))) +@interface SCSLong : SCSNumber +- (instancetype)initWithLongLong:(long long)value; ++ (instancetype)numberWithLongLong:(long long)value; +@end + +__attribute__((swift_name("KotlinULong"))) +@interface SCSULong : SCSNumber +- (instancetype)initWithUnsignedLongLong:(unsigned long long)value; ++ (instancetype)numberWithUnsignedLongLong:(unsigned long long)value; +@end + +__attribute__((swift_name("KotlinFloat"))) +@interface SCSFloat : SCSNumber +- (instancetype)initWithFloat:(float)value; ++ (instancetype)numberWithFloat:(float)value; +@end + +__attribute__((swift_name("KotlinDouble"))) +@interface SCSDouble : SCSNumber +- (instancetype)initWithDouble:(double)value; ++ (instancetype)numberWithDouble:(double)value; +@end + +__attribute__((swift_name("KotlinBoolean"))) +@interface SCSBoolean : SCSNumber +- (instancetype)initWithBool:(BOOL)value; ++ (instancetype)numberWithBool:(BOOL)value; +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieColdFlowIterator"))) +@interface SCSSkieColdFlowIterator : SCSBase +- (instancetype)initWithFlow:(id)flow __attribute__((swift_name("init(flow:)"))) __attribute__((objc_designated_initializer)); +- (void)cancel __attribute__((swift_name("cancel()"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)hasNextWithCompletionHandler:(void (^)(SCSBoolean * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("hasNext(completionHandler:)"))); +- (E _Nullable)next __attribute__((swift_name("next()"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreFlow"))) +@protocol SCSKotlinx_coroutines_coreFlow +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinFlow"))) +@interface SCSSkieKotlinFlow<__covariant T> : SCSBase +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreSharedFlow"))) +@protocol SCSKotlinx_coroutines_coreSharedFlow +@required +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreFlowCollector"))) +@protocol SCSKotlinx_coroutines_coreFlowCollector +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(id _Nullable)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreMutableSharedFlow"))) +@protocol SCSKotlinx_coroutines_coreMutableSharedFlow +@required + +/** + * @note annotations + * kotlinx.coroutines.ExperimentalCoroutinesApi +*/ +- (void)resetReplayCache __attribute__((swift_name("resetReplayCache()"))); +- (BOOL)tryEmitValue:(id _Nullable)value __attribute__((swift_name("tryEmit(value:)"))); +@property (readonly) id subscriptionCount __attribute__((swift_name("subscriptionCount"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinMutableSharedFlow"))) +@interface SCSSkieKotlinMutableSharedFlow : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) id subscriptionCount __attribute__((swift_name("subscriptionCount"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(T)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); + +/** + * @note annotations + * kotlinx.coroutines.ExperimentalCoroutinesApi +*/ +- (void)resetReplayCache __attribute__((swift_name("resetReplayCache()"))); +- (BOOL)tryEmitValue:(T)value __attribute__((swift_name("tryEmit(value:)"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreStateFlow"))) +@protocol SCSKotlinx_coroutines_coreStateFlow +@required +@property (readonly) id _Nullable value __attribute__((swift_name("value"))); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreMutableStateFlow"))) +@protocol SCSKotlinx_coroutines_coreMutableStateFlow +@required +- (void)setValue:(id _Nullable)value __attribute__((swift_name("setValue(_:)"))); +- (BOOL)compareAndSetExpect:(id _Nullable)expect update:(id _Nullable)update __attribute__((swift_name("compareAndSet(expect:update:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinMutableStateFlow"))) +@interface SCSSkieKotlinMutableStateFlow : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) id subscriptionCount __attribute__((swift_name("subscriptionCount"))); +@property T value __attribute__((swift_name("value"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +- (BOOL)compareAndSetExpect:(T)expect update:(T)update __attribute__((swift_name("compareAndSet(expect:update:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(T)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); + +/** + * @note annotations + * kotlinx.coroutines.ExperimentalCoroutinesApi +*/ +- (void)resetReplayCache __attribute__((swift_name("resetReplayCache()"))); +- (BOOL)tryEmitValue:(T)value __attribute__((swift_name("tryEmit(value:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinOptionalFlow"))) +@interface SCSSkieKotlinOptionalFlow<__covariant T> : SCSBase +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinOptionalMutableSharedFlow"))) +@interface SCSSkieKotlinOptionalMutableSharedFlow : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) id subscriptionCount __attribute__((swift_name("subscriptionCount"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(T _Nullable)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); + +/** + * @note annotations + * kotlinx.coroutines.ExperimentalCoroutinesApi +*/ +- (void)resetReplayCache __attribute__((swift_name("resetReplayCache()"))); +- (BOOL)tryEmitValue:(T _Nullable)value __attribute__((swift_name("tryEmit(value:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinOptionalMutableStateFlow"))) +@interface SCSSkieKotlinOptionalMutableStateFlow : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) id subscriptionCount __attribute__((swift_name("subscriptionCount"))); +@property T _Nullable value __attribute__((swift_name("value"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +- (BOOL)compareAndSetExpect:(T _Nullable)expect update:(T _Nullable)update __attribute__((swift_name("compareAndSet(expect:update:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)emitValue:(T _Nullable)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("emit(value:completionHandler:)"))); + +/** + * @note annotations + * kotlinx.coroutines.ExperimentalCoroutinesApi +*/ +- (void)resetReplayCache __attribute__((swift_name("resetReplayCache()"))); +- (BOOL)tryEmitValue:(T _Nullable)value __attribute__((swift_name("tryEmit(value:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinOptionalSharedFlow"))) +@interface SCSSkieKotlinOptionalSharedFlow<__covariant T> : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinOptionalStateFlow"))) +@interface SCSSkieKotlinOptionalStateFlow<__covariant T> : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) T _Nullable value __attribute__((swift_name("value"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinSharedFlow"))) +@interface SCSSkieKotlinSharedFlow<__covariant T> : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("SkieKotlinStateFlow"))) +@interface SCSSkieKotlinStateFlow<__covariant T> : SCSBase +@property (readonly) NSArray *replayCache __attribute__((swift_name("replayCache"))); +@property (readonly) T value __attribute__((swift_name("value"))); +- (instancetype)initWithDelegate:(id)delegate __attribute__((swift_name("init(_:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)collectCollector:(id)collector completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("collect(collector:completionHandler:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Skie_CancellationHandler"))) +@interface SCSSkie_CancellationHandler : SCSBase +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (void)cancel __attribute__((swift_name("cancel()"))); +@end + +__attribute__((swift_name("Skie_DispatcherDelegate"))) +@protocol SCSSkie_DispatcherDelegate +@required +- (void)dispatchBlock:(id)block __attribute__((swift_name("dispatch(block:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Skie_SuspendHandler"))) +@interface SCSSkie_SuspendHandler : SCSBase +- (instancetype)initWithCancellationHandler:(SCSSkie_CancellationHandler *)cancellationHandler dispatcherDelegate:(id)dispatcherDelegate onResult:(void (^)(SCSSkie_SuspendResult *))onResult __attribute__((swift_name("init(cancellationHandler:dispatcherDelegate:onResult:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("Skie_SuspendResult"))) +@interface SCSSkie_SuspendResult : SCSBase +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Skie_SuspendResult.Canceled"))) +@interface SCSSkie_SuspendResultCanceled : SCSSkie_SuspendResult +@property (class, readonly, getter=shared) SCSSkie_SuspendResultCanceled *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)canceled __attribute__((swift_name("init()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Skie_SuspendResult.Error"))) +@interface SCSSkie_SuspendResultError : SCSSkie_SuspendResult +@property (readonly) NSError *error __attribute__((swift_name("error"))); +- (instancetype)initWithError:(NSError *)error __attribute__((swift_name("init(error:)"))) __attribute__((objc_designated_initializer)); +- (SCSSkie_SuspendResultError *)doCopyError:(NSError *)error __attribute__((swift_name("doCopy(error:)"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Skie_SuspendResult.Success"))) +@interface SCSSkie_SuspendResultSuccess : SCSSkie_SuspendResult +@property (readonly) id _Nullable value __attribute__((swift_name("value"))); +- (instancetype)initWithValue:(id _Nullable)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); +- (SCSSkie_SuspendResultSuccess *)doCopyValue:(id _Nullable)value __attribute__((swift_name("doCopy(value:)"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((swift_name("Freshness"))) +@protocol SCSFreshness +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessCachedOrFetch"))) +@interface SCSFreshnessCachedOrFetch : SCSBase +@property (class, readonly, getter=shared) SCSFreshnessCachedOrFetch *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)cachedOrFetch __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessLocalOnly"))) +@interface SCSFreshnessLocalOnly : SCSBase +@property (class, readonly, getter=shared) SCSFreshnessLocalOnly *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)localOnly __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessMaxAge"))) +@interface SCSFreshnessMaxAge : SCSBase +@property (readonly) int64_t notOlderThan __attribute__((swift_name("notOlderThan"))); +- (instancetype)initWithNotOlderThan:(int64_t)notOlderThan __attribute__((swift_name("init(notOlderThan:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessMustBeFresh"))) +@interface SCSFreshnessMustBeFresh : SCSBase +@property (class, readonly, getter=shared) SCSFreshnessMustBeFresh *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)mustBeFresh __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessStaleIfError"))) +@interface SCSFreshnessStaleIfError : SCSBase +@property (class, readonly, getter=shared) SCSFreshnessStaleIfError *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)staleIfError __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((swift_name("KotlinComparable"))) +@protocol SCSKotlinComparable +@required +- (int32_t)compareToOther:(id _Nullable)other __attribute__((swift_name("compareTo(other:)"))); +@end + +__attribute__((swift_name("KotlinEnum"))) +@interface SCSKotlinEnum : SCSBase +@property (class, readonly, getter=companion) SCSKotlinEnumCompanion *companion __attribute__((swift_name("companion"))); +@property (readonly) NSString *name __attribute__((swift_name("name"))); +@property (readonly) int32_t ordinal __attribute__((swift_name("ordinal"))); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)); +- (int32_t)compareToOther:(E)other __attribute__((swift_name("compareTo(other:)"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("Origin"))) +@interface SCSOrigin : SCSKotlinEnum +@property (class, readonly) SCSOrigin *memory __attribute__((swift_name("memory"))); +@property (class, readonly) SCSOrigin *sot __attribute__((swift_name("sot"))); +@property (class, readonly) SCSOrigin *fetcher __attribute__((swift_name("fetcher"))); +@property (class, readonly) SCSOrigin *overlay __attribute__((swift_name("overlay"))); +@property (class, readonly) NSArray *entries __attribute__((swift_name("entries"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (instancetype)initWithName:(NSString *)name ordinal:(int32_t)ordinal __attribute__((swift_name("init(name:ordinal:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); ++ (SCSKotlinArray *)values __attribute__((swift_name("values()"))); +@end + + +/** + * @note annotations + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Store"))) +@protocol SCSStore +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clear(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)clearNamespaceNamespace:(SCSStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("clearNamespace(namespace:completionHandler:)"))); +- (void)close __attribute__((swift_name("close()"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)getKey:(id)key freshness:(id)freshness completionHandler:(void (^)(id _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("get(key:freshness:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidate(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invalidateNamespaceNamespace:(SCSStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("invalidateNamespace(namespace:completionHandler:)"))); +- (id)streamKey:(id)key freshness:(id)freshness __attribute__((swift_name("stream(key:freshness:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreBuilder"))) +@interface SCSStoreBuilder : SCSBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)bookkeeperBookkeeper:(id)bookkeeper __attribute__((swift_name("bookkeeper(bookkeeper:)"))); +- (void)fetcherFetch:(id)fetch __attribute__((swift_name("fetcher(fetch:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)fetcherFetcher:(id)fetcher __attribute__((swift_name("fetcher(fetcher:)"))); +- (void)fetcherOfResultFetch:(id)fetch __attribute__((swift_name("fetcherOfResult(fetch:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)freshnessValidatorValidator:(id)validator __attribute__((swift_name("freshnessValidator(validator:)"))); +- (void)maxIdleKeysCount:(int32_t)count __attribute__((swift_name("maxIdleKeys(count:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)overlayOverlay:(id)overlay __attribute__((swift_name("overlay(overlay:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)persistenceSot:(id)sot __attribute__((swift_name("persistence(sot:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)telemetryTelemetry:(id)telemetry __attribute__((swift_name("telemetry(telemetry:)"))); + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +- (void)wallClockWallClock:(id)wallClock __attribute__((swift_name("wallClock(wallClock:)"))); +@end + +__attribute__((swift_name("StoreError"))) +@interface SCSStoreError : SCSBase +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Conflict"))) +@interface SCSStoreErrorConflict : SCSStoreError +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@property (readonly) id _Nullable serverMeta __attribute__((swift_name("serverMeta"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Conversion"))) +@interface SCSStoreErrorConversion : SCSStoreError +@property (readonly) SCSKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Fetch"))) +@interface SCSStoreErrorFetch : SCSStoreError +@property (readonly) SCSKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.FreshnessUnsatisfiable"))) +@interface SCSStoreErrorFreshnessUnsatisfiable : SCSStoreError +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Missing"))) +@interface SCSStoreErrorMissing : SCSStoreError +@property (readonly) id key __attribute__((swift_name("key"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreError.Persistence"))) +@interface SCSStoreErrorPersistence : SCSStoreError +@property (readonly) SCSKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString *message __attribute__((swift_name("message"))); +@end + +__attribute__((swift_name("KotlinThrowable"))) +@interface SCSKotlinThrowable : SCSBase +@property (readonly) SCSKotlinThrowable * _Nullable cause __attribute__((swift_name("cause"))); +@property (readonly) NSString * _Nullable message __attribute__((swift_name("message"))); +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); + +/** + * @note annotations + * kotlin.experimental.ExperimentalNativeApi +*/ +- (SCSKotlinArray *)getStackTrace __attribute__((swift_name("getStackTrace()"))); +- (void)printStackTrace __attribute__((swift_name("printStackTrace()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +- (NSError *)asError __attribute__((swift_name("asError()"))); +@end + +__attribute__((swift_name("KotlinException"))) +@interface SCSKotlinException : SCSKotlinThrowable +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("KotlinRuntimeException"))) +@interface SCSKotlinRuntimeException : SCSKotlinException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreException"))) +@interface SCSStoreException : SCSKotlinRuntimeException +@property (readonly) SCSStoreError *error __attribute__((swift_name("error"))); +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); ++ (instancetype)new __attribute__((unavailable)); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +- (instancetype)initWithCause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)) __attribute__((unavailable)); +@end + +__attribute__((swift_name("StoreKey"))) +@protocol SCSStoreKey +@required +- (NSString *)canonicalId __attribute__((swift_name("canonicalId()"))); +@property (readonly, getter=namespace) SCSStoreNamespace *namespace_ __attribute__((swift_name("namespace_"))); +@end + +__attribute__((swift_name("StoreMeta"))) +@protocol SCSStoreMeta +@required +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@property (readonly) int64_t writtenAtEpochMillis __attribute__((swift_name("writtenAtEpochMillis"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreNamespace"))) +@interface SCSStoreNamespace : SCSBase +@property (readonly) NSString *value __attribute__((swift_name("value"))); +- (instancetype)initWithValue:(NSString *)value __attribute__((swift_name("init(value:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("StoreResult"))) +@protocol SCSStoreResult +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultData"))) +@interface SCSStoreResultData : SCSBase +@property (readonly) int64_t age __attribute__((swift_name("age"))); +@property (readonly) BOOL isStale __attribute__((swift_name("isStale"))); +@property (readonly) SCSOrigin *origin __attribute__((swift_name("origin"))); +@property (readonly) BOOL refreshing __attribute__((swift_name("refreshing"))); +@property (readonly) V _Nullable value __attribute__((swift_name("value"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultError"))) +@interface SCSStoreResultError : SCSBase +@property (readonly) SCSStoreError *error __attribute__((swift_name("error"))); +@property (readonly) BOOL servedStale __attribute__((swift_name("servedStale"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultLoading"))) +@interface SCSStoreResultLoading : SCSBase +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResultRevalidated"))) +@interface SCSStoreResultRevalidated : SCSBase +@property (readonly) int64_t age __attribute__((swift_name("age"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Bookkeeper"))) +@protocol SCSBookkeeper +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)advanceGlobalStaleWatermarkWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("advanceGlobalStaleWatermark(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)advanceStaleWatermarkNamespace:(SCSStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("advanceStaleWatermark(namespace:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forget(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forgetAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)forgetNamespaceNamespace:(SCSStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("forgetNamespace(namespace:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)markStaleKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("markStale(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)recordFailureKey:(id)key atEpochMillis:(int64_t)atEpochMillis completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("recordFailure(key:atEpochMillis:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)recordSuccessKey:(id)key meta:(id)meta completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("recordSuccess(key:meta:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)statusKey:(id)key completionHandler:(void (^)(SCSKeyStatus * _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("status(key:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("FetchPlan"))) +@protocol SCSFetchPlan +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetchPlanConditional"))) +@interface SCSFetchPlanConditional : SCSBase +@property (readonly) NSString *etag __attribute__((swift_name("etag"))); +@property (readonly) BOOL servesResidentWhileFetching __attribute__((swift_name("servesResidentWhileFetching"))); +- (instancetype)initWithEtag:(NSString *)etag servesResidentWhileFetching:(BOOL)servesResidentWhileFetching __attribute__((swift_name("init(etag:servesResidentWhileFetching:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetchPlanFetch"))) +@interface SCSFetchPlanFetch : SCSBase +@property (readonly) BOOL servesResidentWhileFetching __attribute__((swift_name("servesResidentWhileFetching"))); +- (instancetype)initWithServesResidentWhileFetching:(BOOL)servesResidentWhileFetching __attribute__((swift_name("init(servesResidentWhileFetching:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetchPlanSkip"))) +@interface SCSFetchPlanSkip : SCSBase +@property (class, readonly, getter=shared) SCSFetchPlanSkip *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)skip __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Fetcher"))) +@protocol SCSFetcher +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)fetchKey:(id)key etag:(NSString * _Nullable)etag completionHandler:(void (^)(id _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("fetch(key:etag:completionHandler:)"))); +@end + +__attribute__((swift_name("FetcherResult"))) +@protocol SCSFetcherResult +@required +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultDeleted"))) +@interface SCSFetcherResultDeleted : SCSBase +@property (class, readonly, getter=shared) SCSFetcherResultDeleted *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)deleted __attribute__((swift_name("init()"))); +- (BOOL)isEqual:(id _Nullable)other __attribute__((swift_name("isEqual(_:)"))); +- (NSUInteger)hash __attribute__((swift_name("hash()"))); +- (NSString *)description __attribute__((swift_name("description()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultError"))) +@interface SCSFetcherResultError : SCSBase +@property (readonly) SCSKotlinThrowable *cause __attribute__((swift_name("cause"))); +- (instancetype)initWithCause:(SCSKotlinThrowable *)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultNotModified"))) +@interface SCSFetcherResultNotModified : SCSBase +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +- (instancetype)initWithEtag:(NSString * _Nullable)etag __attribute__((swift_name("init(etag:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FetcherResultSuccess"))) +@interface SCSFetcherResultSuccess : SCSBase +@property (readonly) NSString * _Nullable etag __attribute__((swift_name("etag"))); +@property (readonly) V value __attribute__((swift_name("value"))); +- (instancetype)initWithValue:(V)value etag:(NSString * _Nullable)etag __attribute__((swift_name("init(value:etag:)"))) __attribute__((objc_designated_initializer)); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("FreshnessContext"))) +@interface SCSFreshnessContext : SCSBase +@property (readonly) BOOL epochStale __attribute__((swift_name("epochStale"))); +@property (readonly) id freshness __attribute__((swift_name("freshness"))); +@property (readonly) BOOL hasResidentValue __attribute__((swift_name("hasResidentValue"))); +@property (readonly) id _Nullable meta __attribute__((swift_name("meta"))); +@property (readonly) int64_t nowEpochMillis __attribute__((swift_name("nowEpochMillis"))); +@property (readonly) SCSKeyStatus * _Nullable status __attribute__((swift_name("status"))); +- (instancetype)initWithHasResidentValue:(BOOL)hasResidentValue meta:(id _Nullable)meta epochStale:(BOOL)epochStale freshness:(id)freshness nowEpochMillis:(int64_t)nowEpochMillis status:(SCSKeyStatus * _Nullable)status __attribute__((swift_name("init(hasResidentValue:meta:epochStale:freshness:nowEpochMillis:status:)"))) __attribute__((objc_designated_initializer)); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("FreshnessValidator"))) +@protocol SCSFreshnessValidator +@required +- (id)planContext:(SCSFreshnessContext *)context __attribute__((swift_name("plan(context:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((swift_name("KeyEvents"))) +@interface SCSKeyEvents : SCSBase +@property (readonly) id key __attribute__((swift_name("key"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyEvents.Deleted"))) +@interface SCSKeyEventsDeleted : SCSKeyEvents +@property (readonly) id key __attribute__((swift_name("key"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyEvents.Invalidated"))) +@interface SCSKeyEventsInvalidated : SCSKeyEvents +@property (readonly) id key __attribute__((swift_name("key"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyEvents.Written"))) +@interface SCSKeyEventsWritten : SCSKeyEvents +@property (readonly) id key __attribute__((swift_name("key"))); +@property (readonly) SCSOrigin *origin __attribute__((swift_name("origin"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KeyStatus"))) +@interface SCSKeyStatus : SCSBase +@property (readonly) int32_t consecutiveFailures __attribute__((swift_name("consecutiveFailures"))); +@property (readonly) BOOL durablyStale __attribute__((swift_name("durablyStale"))); +@property (readonly) SCSLong * _Nullable lastFailureAtEpochMillis __attribute__((swift_name("lastFailureAtEpochMillis"))); +@property (readonly) SCSLong * _Nullable lastSuccessSequence __attribute__((swift_name("lastSuccessSequence"))); +@property (readonly) id _Nullable meta __attribute__((swift_name("meta"))); +- (instancetype)initWithMeta:(id _Nullable)meta lastSuccessSequence:(SCSLong * _Nullable)lastSuccessSequence lastFailureAtEpochMillis:(SCSLong * _Nullable)lastFailureAtEpochMillis consecutiveFailures:(int32_t)consecutiveFailures durablyStale:(BOOL)durablyStale __attribute__((swift_name("init(meta:lastSuccessSequence:lastFailureAtEpochMillis:consecutiveFailures:durablyStale:)"))) __attribute__((objc_designated_initializer)); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("Overlay"))) +@protocol SCSOverlay +@required +- (id _Nullable)applyKey:(id)key base:(id _Nullable)base __attribute__((swift_name("apply(key:base:)"))); +@property (readonly) id changes __attribute__((swift_name("changes"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("SourceOfTruth"))) +@protocol SCSSourceOfTruth +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteKey:(id)key completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("delete(key:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteAllWithCompletionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("deleteAll(completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)deleteNamespaceNamespace:(SCSStoreNamespace *)namespace_ completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("deleteNamespace(namespace:completionHandler:)"))); +- (id)readerKey:(id)key __attribute__((swift_name("reader(key:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)writeKey:(id)key value:(id)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("write(key:value:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreResults"))) +@interface SCSStoreResults : SCSBase +@property (class, readonly, getter=shared) SCSStoreResults *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)storeResults __attribute__((swift_name("init()"))); +- (SCSStoreErrorConflict *)conflictServerMeta:(id _Nullable)serverMeta message:(NSString *)message __attribute__((swift_name("conflict(serverMeta:message:)"))); +- (SCSStoreErrorConversion *)conversionErrorMessage:(NSString *)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("conversionError(message:cause:)"))); +- (SCSStoreResultData *)dataValue:(id _Nullable)value origin:(SCSOrigin *)origin age:(int64_t)age isStale:(BOOL)isStale refreshing:(BOOL)refreshing __attribute__((swift_name("data(value:origin:age:isStale:refreshing:)"))); +- (SCSStoreResultError *)errorError:(SCSStoreError *)error servedStale:(BOOL)servedStale __attribute__((swift_name("error(error:servedStale:)"))); +- (SCSStoreException *)exceptionError:(SCSStoreError *)error cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("exception(error:cause:)"))); +- (SCSStoreErrorFetch *)fetchErrorMessage:(NSString *)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("fetchError(message:cause:)"))); +- (SCSStoreErrorFreshnessUnsatisfiable *)freshnessUnsatisfiableMessage:(NSString *)message __attribute__((swift_name("freshnessUnsatisfiable(message:)"))); +- (SCSStoreResultLoading *)loading __attribute__((swift_name("loading()"))); +- (SCSStoreErrorMissing *)missingKey:(id)key message:(NSString *)message __attribute__((swift_name("missing(key:message:)"))); +- (SCSStoreErrorPersistence *)persistenceErrorMessage:(NSString *)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("persistenceError(message:cause:)"))); +- (SCSStoreResultRevalidated *)revalidatedAge:(int64_t)age __attribute__((swift_name("revalidated(age:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("StoreRuntime"))) +@protocol SCSStoreRuntime +@required +@property (readonly) id keyEvents __attribute__((swift_name("keyEvents"))); +@property (readonly) id _Nullable telemetry __attribute__((swift_name("telemetry"))); +@property (readonly) id writeHandle __attribute__((swift_name("writeHandle"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("StoreTelemetry"))) +@protocol SCSStoreTelemetry +@required +- (void)onClearedKey:(id)key __attribute__((swift_name("onCleared(key:)"))); +- (void)onFetchFailedKey:(id)key error:(SCSStoreError *)error duration:(int64_t)duration __attribute__((swift_name("onFetchFailed(key:error:duration:)"))); +- (void)onFetchStartedKey:(id)key __attribute__((swift_name("onFetchStarted(key:)"))); +- (void)onFetchSucceededKey:(id)key duration:(int64_t)duration __attribute__((swift_name("onFetchSucceeded(key:duration:)"))); +- (void)onInvalidatedKey:(id)key __attribute__((swift_name("onInvalidated(key:)"))); +- (void)onServeKey:(id)key origin:(SCSOrigin *)origin __attribute__((swift_name("onServe(key:origin:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("StoreWriteHandle"))) +@protocol SCSStoreWriteHandle +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)applyKey:(id)key value:(id)value completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("apply(key:value:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)confirmFreshKey:(id)key etag:(NSString * _Nullable)etag completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("confirmFresh(key:etag:completionHandler:)"))); + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)markStaleKey:(id)key completionHandler_:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("markStale(key:completionHandler_:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("TransactionalSourceOfTruth"))) +@protocol SCSTransactionalSourceOfTruth +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)withTransactionBlock:(id)block completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("withTransaction(block:completionHandler:)"))); +@end + + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi + * kotlin.SubclassOptInRequired(markerClass=[NormalClass(value=org/mobilenativefoundation/store6/core/DelicateStoreApi)]) +*/ +__attribute__((swift_name("WallClock"))) +@protocol SCSWallClock +@required +- (int64_t)nowEpochMillis __attribute__((swift_name("nowEpochMillis()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreBuilderKt"))) +@interface SCSStoreBuilderKt : SCSBase ++ (id)storeConfigure:(void (^)(SCSStoreBuilder, id> *))configure __attribute__((swift_name("store(configure:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("StoreRuntimeKt"))) +@interface SCSStoreRuntimeKt : SCSBase + +/** + * @note annotations + * org.mobilenativefoundation.store6.core.ExperimentalStoreApi +*/ ++ (id _Nullable)runtime:(id)receiver __attribute__((swift_name("runtime(_:)"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("__SkieSuspendWrappersKt"))) +@interface SCS__SkieSuspendWrappersKt : SCSBase ++ (void)Skie_Suspend__0__clearDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__0__clear(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__10__deleteAllDispatchReceiver:(id)dispatchReceiver suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__10__deleteAll(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__11__deleteNamespaceDispatchReceiver:(id)dispatchReceiver namespace:(SCSStoreNamespace *)namespace_ suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__11__deleteNamespace(dispatchReceiver:namespace:suspendHandler:)"))); ++ (void)Skie_Suspend__12__writeDispatchReceiver:(id)dispatchReceiver key:(id)key value:(id)value suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__12__write(dispatchReceiver:key:value:suspendHandler:)"))); ++ (void)Skie_Suspend__13__invokeDispatchReceiver:(id)dispatchReceiver p1:(id _Nullable)p1 suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__13__invoke(dispatchReceiver:p1:suspendHandler:)"))); ++ (void)Skie_Suspend__14__fetchDispatchReceiver:(id)dispatchReceiver key:(id)key etag:(NSString * _Nullable)etag suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__14__fetch(dispatchReceiver:key:etag:suspendHandler:)"))); ++ (void)Skie_Suspend__15__advanceGlobalStaleWatermarkDispatchReceiver:(id)dispatchReceiver suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__15__advanceGlobalStaleWatermark(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__16__advanceStaleWatermarkDispatchReceiver:(id)dispatchReceiver namespace:(SCSStoreNamespace *)namespace_ suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__16__advanceStaleWatermark(dispatchReceiver:namespace:suspendHandler:)"))); ++ (void)Skie_Suspend__17__forgetDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__17__forget(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__18__forgetAllDispatchReceiver:(id)dispatchReceiver suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__18__forgetAll(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__19__forgetNamespaceDispatchReceiver:(id)dispatchReceiver namespace:(SCSStoreNamespace *)namespace_ suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__19__forgetNamespace(dispatchReceiver:namespace:suspendHandler:)"))); ++ (void)Skie_Suspend__1__clearAllDispatchReceiver:(id)dispatchReceiver suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__1__clearAll(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__20__markStaleDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__20__markStale(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__21__recordFailureDispatchReceiver:(id)dispatchReceiver key:(id)key atEpochMillis:(int64_t)atEpochMillis suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__21__recordFailure(dispatchReceiver:key:atEpochMillis:suspendHandler:)"))); ++ (void)Skie_Suspend__22__recordSuccessDispatchReceiver:(id)dispatchReceiver key:(id)key meta:(id)meta suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__22__recordSuccess(dispatchReceiver:key:meta:suspendHandler:)"))); ++ (void)Skie_Suspend__23__statusDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__23__status(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__24__applyDispatchReceiver:(id)dispatchReceiver key:(id)key value:(id)value suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__24__apply(dispatchReceiver:key:value:suspendHandler:)"))); ++ (void)Skie_Suspend__25__confirmFreshDispatchReceiver:(id)dispatchReceiver key:(id)key etag:(NSString * _Nullable)etag suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__25__confirmFresh(dispatchReceiver:key:etag:suspendHandler:)"))); ++ (void)Skie_Suspend__26__markStaleDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__26__markStale(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__27__withTransactionDispatchReceiver:(id)dispatchReceiver block:(id)block suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__27__withTransaction(dispatchReceiver:block:suspendHandler:)"))); ++ (void)Skie_Suspend__28__invokeDispatchReceiver:(id)dispatchReceiver suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__28__invoke(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__29__hasNextDispatchReceiver:(SCSSkieColdFlowIterator *)dispatchReceiver suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__29__hasNext(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__2__clearNamespaceDispatchReceiver:(id)dispatchReceiver namespace:(SCSStoreNamespace *)namespace_ suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__2__clearNamespace(dispatchReceiver:namespace:suspendHandler:)"))); ++ (void)Skie_Suspend__3__getDispatchReceiver:(id)dispatchReceiver key:(id)key freshness:(id)freshness suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__3__get(dispatchReceiver:key:freshness:suspendHandler:)"))); ++ (void)Skie_Suspend__4__invalidateDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__4__invalidate(dispatchReceiver:key:suspendHandler:)"))); ++ (void)Skie_Suspend__5__invalidateAllDispatchReceiver:(id)dispatchReceiver suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__5__invalidateAll(dispatchReceiver:suspendHandler:)"))); ++ (void)Skie_Suspend__6__invalidateNamespaceDispatchReceiver:(id)dispatchReceiver namespace:(SCSStoreNamespace *)namespace_ suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__6__invalidateNamespace(dispatchReceiver:namespace:suspendHandler:)"))); ++ (void)Skie_Suspend__7__collectDispatchReceiver:(id)dispatchReceiver collector:(id)collector suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__7__collect(dispatchReceiver:collector:suspendHandler:)"))); ++ (void)Skie_Suspend__8__emitDispatchReceiver:(id)dispatchReceiver value:(id _Nullable)value suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__8__emit(dispatchReceiver:value:suspendHandler:)"))); ++ (void)Skie_Suspend__9__deleteDispatchReceiver:(id)dispatchReceiver key:(id)key suspendHandler:(SCSSkie_SuspendHandler *)suspendHandler __attribute__((swift_name("Skie_Suspend__9__delete(dispatchReceiver:key:suspendHandler:)"))); +@end + +__attribute__((swift_name("KotlinIllegalStateException"))) +@interface SCSKotlinIllegalStateException : SCSKotlinRuntimeException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + + +/** + * @note annotations + * kotlin.SinceKotlin(version="1.4") +*/ +__attribute__((swift_name("KotlinCancellationException"))) +@interface SCSKotlinCancellationException : SCSKotlinIllegalStateException +- (instancetype)init __attribute__((swift_name("init()"))) __attribute__((objc_designated_initializer)); ++ (instancetype)new __attribute__((availability(swift, unavailable, message="use object initializers instead"))); +- (instancetype)initWithMessage:(NSString * _Nullable)message __attribute__((swift_name("init(message:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithCause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(cause:)"))) __attribute__((objc_designated_initializer)); +- (instancetype)initWithMessage:(NSString * _Nullable)message cause:(SCSKotlinThrowable * _Nullable)cause __attribute__((swift_name("init(message:cause:)"))) __attribute__((objc_designated_initializer)); +@end + +__attribute__((swift_name("Kotlinx_coroutines_coreRunnable"))) +@protocol SCSKotlinx_coroutines_coreRunnable +@required +- (void)run __attribute__((swift_name("run()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinEnumCompanion"))) +@interface SCSKotlinEnumCompanion : SCSBase +@property (class, readonly, getter=shared) SCSKotlinEnumCompanion *shared __attribute__((swift_name("shared"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); ++ (instancetype)companion __attribute__((swift_name("init()"))); +@end + +__attribute__((objc_subclassing_restricted)) +__attribute__((swift_name("KotlinArray"))) +@interface SCSKotlinArray : SCSBase +@property (readonly) int32_t size __attribute__((swift_name("size"))); ++ (instancetype)arrayWithSize:(int32_t)size init:(T _Nullable (^)(SCSInt *))init __attribute__((swift_name("init(size:init:)"))); ++ (instancetype)alloc __attribute__((unavailable)); ++ (instancetype)allocWithZone:(struct _NSZone *)zone __attribute__((unavailable)); +- (T _Nullable)getIndex:(int32_t)index __attribute__((swift_name("get(index:)"))); +- (id)iterator __attribute__((swift_name("iterator()"))); +- (void)setIndex:(int32_t)index value:(T _Nullable)value __attribute__((swift_name("set(index:value:)"))); +@end + +__attribute__((swift_name("KotlinFunction"))) +@protocol SCSKotlinFunction +@required +@end + +__attribute__((swift_name("KotlinSuspendFunction1"))) +@protocol SCSKotlinSuspendFunction1 +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invokeP1:(id _Nullable)p1 completionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("invoke(p1:completionHandler:)"))); +@end + +__attribute__((swift_name("KotlinSuspendFunction0"))) +@protocol SCSKotlinSuspendFunction0 +@required + +/** + * @note This method converts instances of CancellationException to errors. + * Other uncaught Kotlin exceptions are fatal. +*/ +- (void)invokeWithCompletionHandler:(void (^)(id _Nullable_result, NSError * _Nullable))completionHandler __attribute__((swift_name("invoke(completionHandler:)"))); +@end + +__attribute__((swift_name("KotlinIterator"))) +@protocol SCSKotlinIterator +@required +- (BOOL)hasNext __attribute__((swift_name("hasNext()"))); +- (id _Nullable)next __attribute__((swift_name("next()"))); +@end + +#pragma pop_macro("_Nullable_result") +#pragma clang diagnostic pop +NS_ASSUME_NONNULL_END diff --git a/store6-core/api/swift/skie/Store6CoreSkie.swift b/store6-core/api/swift/skie/Store6CoreSkie.swift new file mode 100644 index 000000000..6c8bc264b --- /dev/null +++ b/store6-core/api/swift/skie/Store6CoreSkie.swift @@ -0,0 +1,2895 @@ +// FILE: KotlinxCoroutinesCore/KotlinxCoroutinesCore.Flow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Kotlinx_coroutines_coreFlow { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func collect(collector: Store6CoreSkie.Kotlinx_coroutines_coreFlowCollector) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__7__collect(dispatchReceiver: self, collector: collector, suspendHandler: $0) + } + } + +} + +// FILE: KotlinxCoroutinesCore/KotlinxCoroutinesCore.FlowCollector.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Kotlinx_coroutines_coreFlowCollector { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func emit(value: Any?) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__8__emit(dispatchReceiver: self, value: value, suspendHandler: $0) + } + } + +} + +// FILE: Skie/Skie.AsyncStreamDispatcherDelegate.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +class AsyncStreamDispatcherDelegate: Skie.co_touchlab_skie__runtime_kotlin.Skie_DispatcherDelegate.__Kotlin { + + private let continuation: _Concurrency.AsyncStream.Continuation + + init(continuation: _Concurrency.AsyncStream.Continuation) { + self.continuation = continuation + } + + func dispatch(block: Skie.org_jetbrains_kotlinx__kotlinx_coroutines_core.Runnable.__Kotlin) { + let result = continuation.yield(block) + + if case .terminated = result { + Swift.fatalError("Cannot dispatch blocks after the dispatcher is stopped. This error might have happened by leaking the dispatcher from the original job.") + } + } + + func stop() { + continuation.finish() + } +} + +// FILE: Skie/Skie.FlowConversions.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +public func SkieKotlinFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +public func SkieKotlinOptionalFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftOptionalFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftOptionalFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +public func SkieKotlinSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftSharedFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftSharedFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftOptionalSharedFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftOptionalSharedFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinMutableSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinMutableSharedFlow { + return Store6CoreSkie.SkieKotlinMutableSharedFlow(flow.delegate) +} + +public func SkieKotlinMutableSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinMutableSharedFlow { + return Store6CoreSkie.SkieKotlinMutableSharedFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftMutableSharedFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftMutableSharedFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinOptionalMutableSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableSharedFlow(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableSharedFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableSharedFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinStateFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinStateFlow { + return Store6CoreSkie.SkieKotlinStateFlow(flow.delegate) +} + +public func SkieKotlinStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinStateFlow { + return Store6CoreSkie.SkieKotlinStateFlow(flow.delegate) +} + +public func SkieKotlinStateFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinStateFlow { + return Store6CoreSkie.SkieKotlinStateFlow(flow.delegate) +} + +public func SkieKotlinStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinStateFlow { + return Store6CoreSkie.SkieKotlinStateFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftStateFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftStateFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalStateFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftOptionalStateFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftOptionalStateFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinMutableStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinMutableStateFlow { + return Store6CoreSkie.SkieKotlinMutableStateFlow(flow.delegate) +} + +public func SkieKotlinMutableStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinMutableStateFlow { + return Store6CoreSkie.SkieKotlinMutableStateFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftMutableStateFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftMutableStateFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +public func SkieKotlinOptionalMutableStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableStateFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableStateFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableStateFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableStateFlow(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableStateFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableStateFlow(flow.delegate) +} + +public func SkieKotlinOptionalMutableStateFlow(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) -> Store6CoreSkie.SkieKotlinOptionalMutableStateFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableStateFlow(flow.delegate) +} + +extension Store6CoreSkie.SkieSwiftOptionalMutableStateFlow where T : Swift.AnyObject { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +extension Store6CoreSkie.SkieSwiftOptionalMutableStateFlow where T : Swift._ObjectiveCBridgeable { + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow) { + self.init(internal: flow) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftMutableStateFlow) { + self.init(internal: flow.delegate) + } + + public convenience init(_ flow: Store6CoreSkie.SkieSwiftOptionalMutableStateFlow) { + self.init(internal: flow.delegate) + } + +} + +// FILE: Skie/Skie.Namespace.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public enum Skie { + + public enum KotlinxCoroutinesCore { + + public enum Flow { + + public typealias __Kotlin = Store6CoreSkie.Kotlinx_coroutines_coreFlow + + } + + public enum StateFlow { + + public typealias __Kotlin = Store6CoreSkie.Kotlinx_coroutines_coreStateFlow + + } + + public enum SharedFlow { + + public typealias __Kotlin = Store6CoreSkie.Kotlinx_coroutines_coreSharedFlow + + } + + public enum MutableSharedFlow { + + public typealias __Kotlin = Store6CoreSkie.Kotlinx_coroutines_coreMutableSharedFlow + + } + + public enum MutableStateFlow { + + public typealias __Kotlin = Store6CoreSkie.Kotlinx_coroutines_coreMutableStateFlow + + } + + public enum Runnable { + + public typealias __Kotlin = Store6CoreSkie.Kotlinx_coroutines_coreRunnable + + } + + } + + public typealias org_jetbrains_kotlinx__kotlinx_coroutines_core = Store6CoreSkie.Skie.KotlinxCoroutinesCore + + public enum RuntimeKotlin { + + public enum SkieColdFlowIterator { + + public typealias __Kotlin = Store6CoreSkie.SkieColdFlowIterator + + } + + public enum SkieKotlinFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinFlow + + } + + public enum SkieKotlinMutableSharedFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinMutableSharedFlow + + } + + public enum SkieKotlinMutableStateFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinMutableStateFlow + + } + + public enum SkieKotlinOptionalFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinOptionalFlow + + } + + public enum SkieKotlinOptionalMutableSharedFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow + + } + + public enum SkieKotlinOptionalMutableStateFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinOptionalMutableStateFlow + + } + + public enum SkieKotlinOptionalSharedFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinOptionalSharedFlow + + } + + public enum SkieKotlinOptionalStateFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinOptionalStateFlow + + } + + public enum SkieKotlinSharedFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinSharedFlow + + } + + public enum SkieKotlinStateFlow { + + public typealias __Kotlin = Store6CoreSkie.SkieKotlinStateFlow + + } + + public enum Skie_CancellationHandler { + + public typealias __Kotlin = Store6CoreSkie.Skie_CancellationHandler + + } + + public enum Skie_DispatcherDelegate { + + public typealias __Kotlin = Store6CoreSkie.Skie_DispatcherDelegate + + } + + public enum Skie_SuspendHandler { + + public typealias __Kotlin = Store6CoreSkie.Skie_SuspendHandler + + } + + public enum Skie_SuspendResult { + + public typealias __Kotlin = Store6CoreSkie.Skie_SuspendResult + + public enum Success { + + public typealias __Kotlin = Store6CoreSkie.Skie_SuspendResult.Success + + } + + public enum Error { + + public typealias __Kotlin = Store6CoreSkie.Skie_SuspendResult.Error + + } + + public enum Canceled { + + public typealias __Kotlin = Store6CoreSkie.Skie_SuspendResult.Canceled + + } + + } + + } + + public typealias co_touchlab_skie__runtime_kotlin = Store6CoreSkie.Skie.RuntimeKotlin + + public enum Store6Core { + + public enum Freshness { + + } + + public enum StoreError { + + } + + public enum FetchPlan { + + } + + public enum FetcherResult { + + } + + public enum StoreResult { + + } + + } + + public typealias Store5__store6_core = Store6CoreSkie.Skie.Store6Core + +} + +// FILE: Skie/Skie.SkieColdFlowIterator.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Skie.RuntimeKotlin.SkieColdFlowIterator { + + public struct __Suspend { + + public let __kotlinObject: Store6CoreSkie.SkieColdFlowIterator + + public init(_ __kotlinObject: Store6CoreSkie.SkieColdFlowIterator) { + self.__kotlinObject = __kotlinObject + } + + } + +} + +public func skie(_ kotlinObject: Store6CoreSkie.SkieColdFlowIterator) -> Store6CoreSkie.Skie.RuntimeKotlin.SkieColdFlowIterator.__Suspend { + return Store6CoreSkie.Skie.RuntimeKotlin.SkieColdFlowIterator.__Suspend(kotlinObject) +} + +extension Store6CoreSkie.Skie.RuntimeKotlin.SkieColdFlowIterator.__Suspend { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func hasNext() async throws -> Store6CoreSkie.KotlinBoolean { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__29__hasNext(dispatchReceiver: __kotlinObject as! Store6CoreSkie.SkieColdFlowIterator, suspendHandler: $0) + } + } + +} + +// FILE: Skie/Skie.SkieSwiftFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreFlow + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinFlow, result: inout Store6CoreSkie.SkieSwiftFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinFlow, result: inout Store6CoreSkie.SkieSwiftFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinFlow { + return Store6CoreSkie.SkieKotlinFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinFlow + +} + +// FILE: Skie/Skie.SkieSwiftFlowIterator.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation +import _Concurrency + +public class SkieSwiftFlowIterator : _Concurrency.AsyncIteratorProtocol { + + private let iterator: Store6CoreSkie.SkieColdFlowIterator + + init(flow: Store6CoreSkie.Kotlinx_coroutines_coreFlow) { + iterator = .init(flow: flow) + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func next() async -> T? { + do { + let hasNext = try await skie(iterator).hasNext() + + if hasNext.boolValue { + return .some(iterator.next() as! Element) + } else { + return nil + } + } catch is _Concurrency.CancellationError { + await cancelTask() + + return nil + } catch { + Swift.fatalError("Unexpected error: \(error)") + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + private func cancelTask() async -> Swift.Void { + _Concurrency.withUnsafeCurrentTask { task in + task?.cancel() + } + } + + deinit { + iterator.cancel() + } + + public typealias Element = T + +} + +// FILE: Skie/Skie.SkieSwiftFlowProtocol.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation +import _Concurrency + +public protocol SkieSwiftFlowProtocol : _Concurrency.AsyncSequence { + + associatedtype Element + associatedtype Delegate : Store6CoreSkie.Kotlinx_coroutines_coreFlow + + @_spi(SKIE) + var delegate: Delegate { get } + +} + +extension Store6CoreSkie.SkieSwiftFlowProtocol { + + var delegate: Delegate { + Swift.fatalError("SkieSwiftFlowProtocol has to be conformed to with @_spi(SKIE) enabled and property 'delegate' implemented") + } + +} + +// FILE: Skie/Skie.SkieSwiftMutableSharedFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftMutableSharedFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreMutableSharedFlow + + public var subscriptionCount: Store6CoreSkie.SkieSwiftStateFlow { + bridgeSubscriptionCount(delegate.subscriptionCount) + } + + public var replayCache: [T] { + delegate.replayCache as! [T] + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreMutableSharedFlow) { + delegate = flow + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func emit(value: T) async throws -> Swift.Void { + try await delegate.emit(value: value) + } + + public func tryEmit(value: T) -> Swift.Bool { + delegate.tryEmit(value: value) + } + + public func resetReplayCache() -> Swift.Void { + delegate.resetReplayCache() + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableSharedFlow, result: inout Store6CoreSkie.SkieSwiftMutableSharedFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableSharedFlow, result: inout Store6CoreSkie.SkieSwiftMutableSharedFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableSharedFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinMutableSharedFlow { + return Store6CoreSkie.SkieKotlinMutableSharedFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableSharedFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftMutableSharedFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinMutableSharedFlow + +} + +// FILE: Skie/Skie.SkieSwiftMutableStateFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftMutableStateFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreMutableStateFlow + + public var value: T { + get { + delegate.value as! T + } + set(value) { + delegate.setValue(value) + } + } + + public var replayCache: [T] { + delegate.replayCache as! [T] + } + + public var subscriptionCount: Store6CoreSkie.SkieSwiftStateFlow { + bridgeSubscriptionCount(delegate.subscriptionCount) + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreMutableStateFlow) { + delegate = flow + } + + public func compareAndSet(expect: T, update: T) -> Swift.Bool { + delegate.compareAndSet(expect: expect, update: update) + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func emit(value: T) async throws -> Swift.Void { + try await delegate.emit(value: value) + } + + public func tryEmit(value: T) -> Swift.Bool { + delegate.tryEmit(value: value) + } + + public func resetReplayCache() -> Swift.Void { + delegate.resetReplayCache() + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableStateFlow, result: inout Store6CoreSkie.SkieSwiftMutableStateFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableStateFlow, result: inout Store6CoreSkie.SkieSwiftMutableStateFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableStateFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinMutableStateFlow { + return Store6CoreSkie.SkieKotlinMutableStateFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinMutableStateFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftMutableStateFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinMutableStateFlow + +} + +// FILE: Skie/Skie.SkieSwiftOptionalFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftOptionalFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreFlow + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalFlow, result: inout Store6CoreSkie.SkieSwiftOptionalFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalFlow, result: inout Store6CoreSkie.SkieSwiftOptionalFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinOptionalFlow { + return Store6CoreSkie.SkieKotlinOptionalFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftOptionalFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T? + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinOptionalFlow + +} + +// FILE: Skie/Skie.SkieSwiftOptionalMutableSharedFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftOptionalMutableSharedFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreMutableSharedFlow + + public var subscriptionCount: Store6CoreSkie.SkieSwiftStateFlow { + bridgeSubscriptionCount(delegate.subscriptionCount) + } + + public var replayCache: [T?] { + delegate.replayCache as! [T?] + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreMutableSharedFlow) { + delegate = flow + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func emit(value: T?) async throws -> Swift.Void { + try await delegate.emit(value: value) + } + + public func tryEmit(value: T?) -> Swift.Bool { + delegate.tryEmit(value: value) + } + + public func resetReplayCache() -> Swift.Void { + delegate.resetReplayCache() + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow, result: inout Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow, result: inout Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftOptionalMutableSharedFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T? + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinOptionalMutableSharedFlow + +} + +// FILE: Skie/Skie.SkieSwiftOptionalMutableStateFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftOptionalMutableStateFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreMutableStateFlow + + public var value: T? { + get { + delegate.value as! T? + } + set(value) { + delegate.setValue(value) + } + } + + public var replayCache: [T?] { + delegate.replayCache as! [T?] + } + + public var subscriptionCount: Store6CoreSkie.SkieSwiftStateFlow { + bridgeSubscriptionCount(delegate.subscriptionCount) + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreMutableStateFlow) { + delegate = flow + } + + public func compareAndSet(expect: T?, update: T?) -> Swift.Bool { + delegate.compareAndSet(expect: expect, update: update) + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func emit(value: T?) async throws -> Swift.Void { + try await delegate.emit(value: value) + } + + public func tryEmit(value: T?) -> Swift.Bool { + delegate.tryEmit(value: value) + } + + public func resetReplayCache() -> Swift.Void { + delegate.resetReplayCache() + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow, result: inout Store6CoreSkie.SkieSwiftOptionalMutableStateFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow, result: inout Store6CoreSkie.SkieSwiftOptionalMutableStateFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinOptionalMutableStateFlow { + return Store6CoreSkie.SkieKotlinOptionalMutableStateFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalMutableStateFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftOptionalMutableStateFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T? + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinOptionalMutableStateFlow + +} + +// FILE: Skie/Skie.SkieSwiftOptionalSharedFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftOptionalSharedFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreSharedFlow + + public var replayCache: [T?] { + delegate.replayCache as! [T?] + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreSharedFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalSharedFlow, result: inout Store6CoreSkie.SkieSwiftOptionalSharedFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalSharedFlow, result: inout Store6CoreSkie.SkieSwiftOptionalSharedFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalSharedFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinOptionalSharedFlow { + return Store6CoreSkie.SkieKotlinOptionalSharedFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalSharedFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftOptionalSharedFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T? + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinOptionalSharedFlow + +} + +// FILE: Skie/Skie.SkieSwiftOptionalStateFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftOptionalStateFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreStateFlow + + public var value: T? { + delegate.value as! T? + } + + public var replayCache: [T?] { + delegate.replayCache as! [T?] + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreStateFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalStateFlow, result: inout Store6CoreSkie.SkieSwiftOptionalStateFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalStateFlow, result: inout Store6CoreSkie.SkieSwiftOptionalStateFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalStateFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinOptionalStateFlow { + return Store6CoreSkie.SkieKotlinOptionalStateFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinOptionalStateFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftOptionalStateFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T? + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinOptionalStateFlow + +} + +// FILE: Skie/Skie.SkieSwiftSharedFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftSharedFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreSharedFlow + + public var replayCache: [T] { + delegate.replayCache as! [T] + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreSharedFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinSharedFlow, result: inout Store6CoreSkie.SkieSwiftSharedFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinSharedFlow, result: inout Store6CoreSkie.SkieSwiftSharedFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinSharedFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinSharedFlow { + return Store6CoreSkie.SkieKotlinSharedFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinSharedFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftSharedFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinSharedFlow + +} + +// FILE: Skie/Skie.SkieSwiftStateFlow.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public final class SkieSwiftStateFlow : Store6CoreSkie.SkieSwiftFlowProtocol, + Swift._ObjectiveCBridgeable { + + @_spi(SKIE) + public let delegate: Store6CoreSkie.Kotlinx_coroutines_coreStateFlow + + public var value: T { + delegate.value as! T + } + + public var replayCache: [T] { + delegate.replayCache as! [T] + } + + init(`internal` flow: Store6CoreSkie.Kotlinx_coroutines_coreStateFlow) { + delegate = flow + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinStateFlow, result: inout Store6CoreSkie.SkieSwiftStateFlow?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinStateFlow, result: inout Store6CoreSkie.SkieSwiftStateFlow?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.SkieKotlinStateFlow?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.SkieKotlinStateFlow { + return Store6CoreSkie.SkieKotlinStateFlow(delegate) + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.SkieKotlinStateFlow?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.SkieSwiftStateFlow") + } + return .init(internal: source) + } + + public func makeAsyncIterator() -> Store6CoreSkie.SkieSwiftFlowIterator { + return SkieSwiftFlowIterator(flow: delegate) + } + + public typealias AsyncIterator = Store6CoreSkie.SkieSwiftFlowIterator + + public typealias Element = T + + public typealias _ObjectiveCType = Store6CoreSkie.SkieKotlinStateFlow + +} + +// FILE: Skie/Skie.Skie_SuspendResult.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Skie.RuntimeKotlin.Skie_SuspendResult { + + @frozen + public enum __Sealed : Swift.Hashable { + + case canceled(Store6CoreSkie.Skie_SuspendResult.Canceled) + case error(Store6CoreSkie.Skie_SuspendResult.Error) + case success(Store6CoreSkie.Skie_SuspendResult.Success) + + } + +} + +public func onEnum<__Sealed : Store6CoreSkie.Skie_SuspendResult>(of sealed: __Sealed) -> Store6CoreSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed { + if let sealed = sealed as? Store6CoreSkie.Skie_SuspendResult.Canceled { + return Store6CoreSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed.canceled(sealed) + } else if let sealed = sealed as? Store6CoreSkie.Skie_SuspendResult.Error { + return Store6CoreSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed.error(sealed) + } else if let sealed = sealed as? Store6CoreSkie.Skie_SuspendResult.Success { + return Store6CoreSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed.success(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6CoreSkie.Skie_SuspendResult is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6CoreSkie.Skie_SuspendResult>(of sealed: __Sealed?) -> Store6CoreSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6CoreSkie.Skie.RuntimeKotlin.Skie_SuspendResult.__Sealed + } else { + return nil + } +} + +// FILE: Skie/Skie.SwiftCoroutineDispatcher.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +struct SwiftCoroutineDispatcher { + + static func dispatch( + coroutine: (Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendHandler.__Kotlin) -> Swift.Void + ) async throws -> T { + let cancellationHandler = Skie.co_touchlab_skie__runtime_kotlin.Skie_CancellationHandler.__Kotlin() + + return try await _Concurrency.withTaskCancellationHandler(operation: { + try await dispatchCancellable(coroutine: coroutine, cancellationHandler: cancellationHandler) + }, onCancel: { + cancellationHandler.cancel() + }) + } + + private static func dispatchCancellable( + coroutine: (Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendHandler.__Kotlin) -> Swift.Void, + cancellationHandler: Skie.co_touchlab_skie__runtime_kotlin.Skie_CancellationHandler.__Kotlin + ) async throws -> T { + var result: Swift.Result? = nil + + let dispatcher = createDispatcher(coroutine: coroutine, cancellationHandler: cancellationHandler) { + result = $0 + } + + await executeWithoutCancellation(dispatcher: dispatcher) + + return try unwrap(result: result) + } + + private static func createDispatcher( + coroutine: (Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendHandler.__Kotlin) -> Swift.Void, + cancellationHandler: Skie.co_touchlab_skie__runtime_kotlin.Skie_CancellationHandler.__Kotlin, + onResult: @escaping (Swift.Result) -> Swift.Void + ) -> _Concurrency.AsyncStream { + return _Concurrency.AsyncStream { continuation in + let dispatcherDelegate = AsyncStreamDispatcherDelegate(continuation: continuation) + + let suspendHandler = Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendHandler.__Kotlin( + cancellationHandler: cancellationHandler, + dispatcherDelegate: dispatcherDelegate, + onResult: { suspendResult in + let result: Swift.Result = convertToResult(suspendResult: suspendResult) + + onResult(result) + + dispatcherDelegate.stop() + } + ) + + coroutine(suspendHandler) + } + } + + private static func convertToResult( + suspendResult: Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendResult.__Kotlin + ) -> Swift.Result { + if let suspendResult = suspendResult as? Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendResult.Success.__Kotlin { + if T.self == Swift.Void.self { + return .success(Swift.Void() as! T) + } else { + return .success(suspendResult.value as! T) + } + } else if let suspendResult = suspendResult as? Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendResult.Error.__Kotlin { + return .failure(suspendResult.error) + } else if suspendResult is Skie.co_touchlab_skie__runtime_kotlin.Skie_SuspendResult.Canceled.__Kotlin { + return .failure(_Concurrency.CancellationError()) + } else { + fatalError("Unknown suspend result. This is most likely a bug in SKIE.") + } + } + + private static func executeWithoutCancellation(dispatcher: _Concurrency.AsyncStream) async { + await _Concurrency.Task { + for await block in dispatcher { + block.run() + } + }.value + } + + private static func unwrap(result: Swift.Result?) throws -> T { + if let result = result { + switch result { + case .success(let value): + return value + case .failure(let error): + throw error + } + } else { + fatalError("Suspend execution ended without result! This is most likely a bug in SKIE.") + } + } +} + +// FILE: Skie/Skie.bridgeSubscriptionCount.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +func bridgeSubscriptionCount(_ subscriptionCount: Store6CoreSkie.SkieSwiftStateFlow) -> Store6CoreSkie.SkieSwiftStateFlow { + return subscriptionCount +} + +func bridgeSubscriptionCount(_ subscriptionCount: any Store6CoreSkie.Kotlinx_coroutines_coreStateFlow) -> Store6CoreSkie.SkieSwiftStateFlow { + return Store6CoreSkie.SkieSwiftStateFlow(internal: subscriptionCount) +} + +// FILE: Stdlib/Stdlib.SuspendFunction0.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.KotlinSuspendFunction0 { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func invoke() async throws -> Any? { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__28__invoke(dispatchReceiver: self, suspendHandler: $0) + } + } + +} + +// FILE: Stdlib/Stdlib.SuspendFunction1.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.KotlinSuspendFunction1 { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func invoke(p1: Any?) async throws -> Any? { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__13__invoke(dispatchReceiver: self, p1: p1, suspendHandler: $0) + } + } + +} + +// FILE: Store6Core/Store6Core.Bookkeeper.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Bookkeeper { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func advanceGlobalStaleWatermark() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__15__advanceGlobalStaleWatermark(dispatchReceiver: self, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func advanceStaleWatermark(namespace: Store6CoreSkie.StoreNamespace) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__16__advanceStaleWatermark(dispatchReceiver: self, namespace: namespace, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func forget(key: Store6CoreSkie.StoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__17__forget(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func forgetAll() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__18__forgetAll(dispatchReceiver: self, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func forgetNamespace(namespace: Store6CoreSkie.StoreNamespace) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__19__forgetNamespace(dispatchReceiver: self, namespace: namespace, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func markStale(key: Store6CoreSkie.StoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__20__markStale(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func recordFailure(key: Store6CoreSkie.StoreKey, atEpochMillis: Swift.Int64) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__21__recordFailure(dispatchReceiver: self, key: key, atEpochMillis: atEpochMillis, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func recordSuccess(key: Store6CoreSkie.StoreKey, meta: Store6CoreSkie.StoreMeta) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__22__recordSuccess(dispatchReceiver: self, key: key, meta: meta, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func status(key: Store6CoreSkie.StoreKey) async throws -> Store6CoreSkie.KeyStatus? { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__23__status(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + +} + +// FILE: Store6Core/Store6Core.FetchPlan.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Skie.Store6Core.FetchPlan { + + @frozen + public enum __Sealed : Swift.Hashable { + + case conditional(Store6CoreSkie.FetchPlanConditional) + case fetch(Store6CoreSkie.FetchPlanFetch) + case skip(Store6CoreSkie.FetchPlanSkip) + + } + +} + +public func onEnum<__Sealed : Store6CoreSkie.FetchPlan>(of sealed: __Sealed) -> Store6CoreSkie.Skie.Store6Core.FetchPlan.__Sealed { + if let sealed = sealed as? Store6CoreSkie.FetchPlanConditional { + return Store6CoreSkie.Skie.Store6Core.FetchPlan.__Sealed.conditional(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FetchPlanFetch { + return Store6CoreSkie.Skie.Store6Core.FetchPlan.__Sealed.fetch(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FetchPlanSkip { + return Store6CoreSkie.Skie.Store6Core.FetchPlan.__Sealed.skip(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6CoreSkie.FetchPlan is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6CoreSkie.FetchPlan>(of sealed: __Sealed?) -> Store6CoreSkie.Skie.Store6Core.FetchPlan.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6CoreSkie.Skie.Store6Core.FetchPlan.__Sealed + } else { + return nil + } +} + +// FILE: Store6Core/Store6Core.Fetcher.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Fetcher { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func fetch(key: Store6CoreSkie.StoreKey, etag: Swift.String?) async throws -> Store6CoreSkie.FetcherResult { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__14__fetch(dispatchReceiver: self, key: key, etag: etag, suspendHandler: $0) + } + } + +} + +// FILE: Store6Core/Store6Core.FetcherResult.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Skie.Store6Core.FetcherResult { + + @frozen + public enum __Sealed : Swift.Hashable { + + case deleted(Store6CoreSkie.FetcherResultDeleted) + case error(Store6CoreSkie.FetcherResultError) + case notModified(Store6CoreSkie.FetcherResultNotModified) + case success(Store6CoreSkie.FetcherResultSuccess) + + } + +} + +public func onEnum<__Sealed : Store6CoreSkie.FetcherResult>(of sealed: __Sealed) -> Store6CoreSkie.Skie.Store6Core.FetcherResult.__Sealed { + if let sealed = sealed as? Store6CoreSkie.FetcherResultDeleted { + return Store6CoreSkie.Skie.Store6Core.FetcherResult.__Sealed.deleted(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FetcherResultError { + return Store6CoreSkie.Skie.Store6Core.FetcherResult.__Sealed.error(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FetcherResultNotModified { + return Store6CoreSkie.Skie.Store6Core.FetcherResult.__Sealed.notModified(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FetcherResultSuccess { + return Store6CoreSkie.Skie.Store6Core.FetcherResult.__Sealed.success(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6CoreSkie.FetcherResult is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6CoreSkie.FetcherResult>(of sealed: __Sealed?) -> Store6CoreSkie.Skie.Store6Core.FetcherResult.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6CoreSkie.Skie.Store6Core.FetcherResult.__Sealed + } else { + return nil + } +} + +// FILE: Store6Core/Store6Core.Freshness.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Skie.Store6Core.Freshness { + + @frozen + public enum __Sealed : Swift.Hashable { + + case cachedOrFetch(Store6CoreSkie.FreshnessCachedOrFetch) + case localOnly(Store6CoreSkie.FreshnessLocalOnly) + case maxAge(Store6CoreSkie.FreshnessMaxAge) + case mustBeFresh(Store6CoreSkie.FreshnessMustBeFresh) + case staleIfError(Store6CoreSkie.FreshnessStaleIfError) + + } + +} + +public func onEnum<__Sealed : Store6CoreSkie.Freshness>(of sealed: __Sealed) -> Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed { + if let sealed = sealed as? Store6CoreSkie.FreshnessCachedOrFetch { + return Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed.cachedOrFetch(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FreshnessLocalOnly { + return Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed.localOnly(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FreshnessMaxAge { + return Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed.maxAge(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FreshnessMustBeFresh { + return Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed.mustBeFresh(sealed) + } else if let sealed = sealed as? Store6CoreSkie.FreshnessStaleIfError { + return Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed.staleIfError(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6CoreSkie.Freshness is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6CoreSkie.Freshness>(of sealed: __Sealed?) -> Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6CoreSkie.Skie.Store6Core.Freshness.__Sealed + } else { + return nil + } +} + +// FILE: Store6Core/Store6Core.Origin.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +@frozen +public enum Origin : Swift.Hashable, Swift.CaseIterable, Swift._ObjectiveCBridgeable { + + case memory + case sot + case fetcher + case overlay + + public var name: Swift.String { + return (self as _ObjectiveCType).name + } + + public var ordinal: Swift.Int32 { + return (self as _ObjectiveCType).ordinal + } + + public static func _forceBridgeFromObjectiveC(_ source: Store6CoreSkie.__Origin, result: inout Store6CoreSkie.Origin?) -> Swift.Void { + result = fromObjectiveC(source) + } + + public static func _conditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.__Origin, result: inout Store6CoreSkie.Origin?) -> Swift.Bool { + result = fromObjectiveC(source) + return true + } + + public static func _unconditionallyBridgeFromObjectiveC(_ source: Store6CoreSkie.__Origin?) -> Self { + return fromObjectiveC(source) + } + + public func _bridgeToObjectiveC() -> Store6CoreSkie.__Origin { + switch self { + case .memory: return Store6CoreSkie.__Origin.memory as Store6CoreSkie.__Origin + case .sot: return Store6CoreSkie.__Origin.sot as Store6CoreSkie.__Origin + case .fetcher: return Store6CoreSkie.__Origin.fetcher as Store6CoreSkie.__Origin + case .overlay: return Store6CoreSkie.__Origin.overlay as Store6CoreSkie.__Origin + } + } + + private static func fromObjectiveC(_ source: Store6CoreSkie.__Origin?) -> Self { + guard let source = source else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.Origin") + } + if source == Store6CoreSkie.__Origin.memory as Store6CoreSkie.__Origin { + return .memory + } else if source == Store6CoreSkie.__Origin.sot as Store6CoreSkie.__Origin { + return .sot + } else if source == Store6CoreSkie.__Origin.fetcher as Store6CoreSkie.__Origin { + return .fetcher + } else if source == Store6CoreSkie.__Origin.overlay as Store6CoreSkie.__Origin { + return .overlay + } else { + fatalError("Couldn't map value of \(Swift.String(describing: source)) to Store6CoreSkie.__Origin") + } + } + + public typealias _ObjectiveCType = Store6CoreSkie.__Origin + +} + +extension Store6CoreSkie.Origin { + + public func toKotlinEnum() -> Store6CoreSkie.__Origin { + return _bridgeToObjectiveC() + } + +} + +extension Store6CoreSkie.__Origin { + + public func toSwiftEnum() -> Store6CoreSkie.Origin { + return Store6CoreSkie.Origin._unconditionallyBridgeFromObjectiveC(self) + } + +} + +// FILE: Store6Core/Store6Core.SourceOfTruth.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.SourceOfTruth { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func delete(key: Store6CoreSkie.StoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__9__delete(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func deleteAll() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__10__deleteAll(dispatchReceiver: self, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func deleteNamespace(namespace: Store6CoreSkie.StoreNamespace) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__11__deleteNamespace(dispatchReceiver: self, namespace: namespace, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func write(key: Store6CoreSkie.StoreKey, value: Any) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__12__write(dispatchReceiver: self, key: key, value: value, suspendHandler: $0) + } + } + +} + +// FILE: Store6Core/Store6Core.Store.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Store { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func clear(key: Store6CoreSkie.StoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__0__clear(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func clearAll() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__1__clearAll(dispatchReceiver: self, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func clearNamespace(namespace: Store6CoreSkie.StoreNamespace) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__2__clearNamespace(dispatchReceiver: self, namespace: namespace, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func get(key: Store6CoreSkie.StoreKey, freshness: Store6CoreSkie.Freshness) async throws -> Any { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__3__get(dispatchReceiver: self, key: key, freshness: freshness, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func invalidate(key: Store6CoreSkie.StoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__4__invalidate(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func invalidateAll() async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__5__invalidateAll(dispatchReceiver: self, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func invalidateNamespace(namespace: Store6CoreSkie.StoreNamespace) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__6__invalidateNamespace(dispatchReceiver: self, namespace: namespace, suspendHandler: $0) + } + } + + public func runtime() -> Store6CoreSkie.StoreRuntime? { + return Store6CoreSkie.StoreRuntimeKt.runtime(self) + } + +} + +// FILE: Store6Core/Store6Core.StoreBuilderKt.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +public func store(configure: @escaping (Store6CoreSkie.StoreBuilder) -> Swift.Void) -> Store6CoreSkie.Store { + return Store6CoreSkie.StoreBuilderKt.store(configure: configure) +} + +// FILE: Store6Core/Store6Core.StoreError.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Skie.Store6Core.StoreError { + + @frozen + public enum __Sealed : Swift.Hashable { + + case conflict(Store6CoreSkie.StoreError.Conflict) + case conversion(Store6CoreSkie.StoreError.Conversion) + case fetch(Store6CoreSkie.StoreError.Fetch) + case freshnessUnsatisfiable(Store6CoreSkie.StoreError.FreshnessUnsatisfiable) + case missing(Store6CoreSkie.StoreError.Missing) + case persistence(Store6CoreSkie.StoreError.Persistence) + + } + +} + +public func onEnum<__Sealed : Store6CoreSkie.StoreError>(of sealed: __Sealed) -> Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed { + if let sealed = sealed as? Store6CoreSkie.StoreError.Conflict { + return Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed.conflict(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreError.Conversion { + return Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed.conversion(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreError.Fetch { + return Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed.fetch(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreError.FreshnessUnsatisfiable { + return Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed.freshnessUnsatisfiable(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreError.Missing { + return Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed.missing(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreError.Persistence { + return Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed.persistence(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6CoreSkie.StoreError is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6CoreSkie.StoreError>(of sealed: __Sealed?) -> Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6CoreSkie.Skie.Store6Core.StoreError.__Sealed + } else { + return nil + } +} + +// FILE: Store6Core/Store6Core.StoreResult.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.Skie.Store6Core.StoreResult { + + @frozen + public enum __Sealed : Swift.Hashable { + + case data(Store6CoreSkie.StoreResultData) + case error(Store6CoreSkie.StoreResultError) + case loading(Store6CoreSkie.StoreResultLoading) + case revalidated(Store6CoreSkie.StoreResultRevalidated) + + } + +} + +public func onEnum<__Sealed : Store6CoreSkie.StoreResult>(of sealed: __Sealed) -> Store6CoreSkie.Skie.Store6Core.StoreResult.__Sealed { + if let sealed = sealed as? Store6CoreSkie.StoreResultData { + return Store6CoreSkie.Skie.Store6Core.StoreResult.__Sealed.data(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreResultError { + return Store6CoreSkie.Skie.Store6Core.StoreResult.__Sealed.error(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreResultLoading { + return Store6CoreSkie.Skie.Store6Core.StoreResult.__Sealed.loading(sealed) + } else if let sealed = sealed as? Store6CoreSkie.StoreResultRevalidated { + return Store6CoreSkie.Skie.Store6Core.StoreResult.__Sealed.revalidated(sealed) + } else { + fatalError("Unknown subtype \(sealed). This error should not happen under normal circumstances since SirClass: Store6CoreSkie.StoreResult is sealed.") + } +} + +@_disfavoredOverload +public func onEnum<__Sealed : Store6CoreSkie.StoreResult>(of sealed: __Sealed?) -> Store6CoreSkie.Skie.Store6Core.StoreResult.__Sealed? { + if let sealed { + return onEnum(of: sealed) as Store6CoreSkie.Skie.Store6Core.StoreResult.__Sealed + } else { + return nil + } +} + +// FILE: Store6Core/Store6Core.StoreWriteHandle.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.StoreWriteHandle { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func apply(key: Store6CoreSkie.StoreKey, value: Any) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__24__apply(dispatchReceiver: self, key: key, value: value, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func confirmFresh(key: Store6CoreSkie.StoreKey, etag: Swift.String?) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__25__confirmFresh(dispatchReceiver: self, key: key, etag: etag, suspendHandler: $0) + } + } + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func markStale(key: Store6CoreSkie.StoreKey) async throws -> Swift.Void { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__26__markStale(dispatchReceiver: self, key: key, suspendHandler: $0) + } + } + +} + +// FILE: Store6Core/Store6Core.TransactionalSourceOfTruth.swift +// Generated by Touchlab SKIE 0.10.13 + +import Foundation + +extension Store6CoreSkie.TransactionalSourceOfTruth { + + @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) + public func withTransaction(block: Store6CoreSkie.KotlinSuspendFunction0) async throws -> Any? { + return try await SwiftCoroutineDispatcher.dispatch { + Store6CoreSkie.__SkieSuspendWrappersKt.Skie_Suspend__27__withTransaction(dispatchReceiver: self, block: block, suspendHandler: $0) + } + } + +} diff --git a/store6-core/build.gradle.kts b/store6-core/build.gradle.kts new file mode 100644 index 000000000..3f50f50bb --- /dev/null +++ b/store6-core/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + id("org.mobilenativefoundation.store.store6.multiplatform") +} + +kotlin { + js { + nodejs { + testTask { + useMocha { + // Keep the runner above the 240s StoreInvalidationStressTest watchdog so + // runTest always owns cancellation and cleanup. + timeout = "300s" + } + } + } + } + + sourceSets { + val commonMain by getting { + dependencies { + api(libs.kotlinx.coroutines.core) + } + } + + val commonTest by getting { + dependencies { + implementation(projects.store6Testing) + implementation(libs.kotlinx.coroutines.test) + implementation(libs.turbine) + } + } + } +} + +android { + namespace = "org.mobilenativefoundation.store6.core" +} diff --git a/store6-core/dokka/Module.md b/store6-core/dokka/Module.md new file mode 100644 index 000000000..8e2d4f50e --- /dev/null +++ b/store6-core/dokka/Module.md @@ -0,0 +1,17 @@ +# Module store6-core + +Store 6 is in development and nothing is published yet. This reference describes the API as it +stands on `main`; install coordinates begin with 6.0.0-alpha01. + +## Stability tier + +`store6-core` is stable-track. Its API is not frozen until the beta01 freeze candidate. + +The `org.mobilenativefoundation.store6.core.seam` package is a freeze candidate, not frozen. Its +types are currently marked `@ExperimentalStoreApi`, so implementing a fetcher, source of truth, +bookkeeper, clock, telemetry sink, or overlay is an explicit opt-in. + +Return to [Docs home](https://store.mobilenativefoundation.org/docs), or use the +[Store 6 overview](https://store.mobilenativefoundation.org/docs/store6/overview) for guides and +examples. Writing APIs are in the +[store6-mutations reference](https://store.mobilenativefoundation.org/reference/store6-mutations/index.html). diff --git a/store6-core/gradle.properties b/store6-core/gradle.properties new file mode 100644 index 000000000..768418c29 --- /dev/null +++ b/store6-core/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=store6-core +POM_ARTIFACT_ID=store6-core diff --git a/core/config/ktlint/baseline.xml b/store6-core/src/androidMain/AndroidManifest.xml similarity index 51% rename from core/config/ktlint/baseline.xml rename to store6-core/src/androidMain/AndroidManifest.xml index 981420778..8072ee00d 100644 --- a/core/config/ktlint/baseline.xml +++ b/store6-core/src/androidMain/AndroidManifest.xml @@ -1,3 +1,2 @@ - - + diff --git a/store6-core/src/androidMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.android.kt b/store6-core/src/androidMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.android.kt new file mode 100644 index 000000000..dbf43f9c0 --- /dev/null +++ b/store6-core/src/androidMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.android.kt @@ -0,0 +1,4 @@ +package org.mobilenativefoundation.store6.core.internal + +/** Returns the Android system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/store6-core/src/appleMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.apple.kt b/store6-core/src/appleMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.apple.kt new file mode 100644 index 000000000..d6ab14697 --- /dev/null +++ b/store6-core/src/appleMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.apple.kt @@ -0,0 +1,8 @@ +package org.mobilenativefoundation.store6.core.internal + +import platform.Foundation.NSDate +import platform.Foundation.timeIntervalSince1970 + +/** Returns the Apple system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = + (NSDate().timeIntervalSince1970 * 1_000.0).toLong() diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Annotations.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Annotations.kt new file mode 100644 index 000000000..827aa29bb --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Annotations.kt @@ -0,0 +1,64 @@ +package org.mobilenativefoundation.store6.core + +/** + * Marks API that is under active development and may change or be removed in any release. + * + * Experimental API ships in separate artifacts wherever possible; this marker exists for the + * cases where an experimental member must live beside stable API. + */ +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "This Store API is experimental and may change or be removed in any release.", +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.TYPEALIAS, +) +@MustBeDocumented +public annotation class ExperimentalStoreApi + +/** + * Marks API that is stable but easy to misuse, such as implementing [Store] directly instead of + * building one through the [store] DSL. Opting in asserts that the caller upholds the documented + * contract of the marked declaration. + */ +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = + "This is a delicate Store API. Read the contract documentation of the declaration " + + "before opting in; implementations must uphold every documented semantic.", +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.TYPEALIAS, +) +@MustBeDocumented +public annotation class DelicateStoreApi + +/** + * Marks API that is internal to the Store libraries. It may change or disappear without notice + * even in patch releases and must never be used outside org.mobilenativefoundation.store + * artifacts. + */ +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "This API is internal to Store and must not be used outside Store artifacts.", +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CONSTRUCTOR, + AnnotationTarget.TYPEALIAS, +) +@MustBeDocumented +public annotation class InternalStoreApi diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Freshness.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Freshness.kt new file mode 100644 index 000000000..e1c02344d --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Freshness.kt @@ -0,0 +1,52 @@ +package org.mobilenativefoundation.store6.core + +import kotlin.time.Duration + +/** + * A per-call policy describing how fresh a value must be before it is served. + * + * Each read is planned from resident availability, invalidation state, typed metadata, and this + * policy. Concurrent requests for one key still share a single in-flight fetch even when their + * policies differ. + */ +public sealed interface Freshness { + /** + * The default policy: serve a locally available value immediately. Invalidated values and + * source-of-truth rows without freshness metadata are served as stale while one background + * revalidation runs; fetch when no local value exists. + */ + public data object CachedOrFetch : Freshness + + /** + * Serve a locally available value only when it has known freshness metadata, has not been + * invalidated, and its age does not exceed `notOlderThan`; otherwise withhold it and fetch a + * fresh value. + */ + public class MaxAge( + /** The oldest a served value may be before a fetch is required. */ + public val notOlderThan: Duration, + ) : Freshness + + /** + * Never serve a cached value; block until a fresh fetch succeeds and fail when it does not. A + * source-of-truth row without freshness metadata is also withheld. + */ + public data object MustBeFresh : Freshness + + /** + * Prefer fresh data after invalidation or when a local value has no freshness metadata, but + * fall back to that stale value when the fetch fails. A local value with current known + * metadata is served without fetching. + */ + public data object StaleIfError : Freshness + + /** + * Never invoke the fetcher; serve only locally available data and report + * [StoreError.Missing] when none exists. + * + * On a memory miss, the configured source of truth is probed once, so a pre-existing persisted + * row is locally available data. The builder still requires a fetcher, but LocalOnly never + * invokes it. + */ + public data object LocalOnly : Freshness +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Origin.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Origin.kt new file mode 100644 index 000000000..d1fe4efd4 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Origin.kt @@ -0,0 +1,16 @@ +package org.mobilenativefoundation.store6.core + +/** Identifies the source from which a [StoreResult.Data] value was obtained. */ +public enum class Origin { + /** The value was served from in-memory resident state. */ + MEMORY, + + /** The value was read from the store's persistent source of truth. */ + SOT, + + /** The value was produced by the store's configured fetcher. */ + FETCHER, + + /** The value reflects an overlay applied above stored data. */ + OVERLAY, +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Store.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Store.kt new file mode 100644 index 000000000..2a9bf2b08 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/Store.kt @@ -0,0 +1,174 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.flow.Flow + +/** + * Provides asynchronous access to non-null values identified by [StoreKey] instances. + * + * Create stores through the [store] DSL. Implementing this interface directly is a delicate + * operation: implementations must uphold every documented semantic, including the one-failure- + * channel rule ([stream] emits and never throws; [get] throws and never emits) and the + * completion postconditions of the maintenance operations. + * + * @param K the key type accepted by this store + * @param V the non-null value type produced by this store + */ +@SubclassOptInRequired(DelicateStoreApi::class) +public interface Store { + /** + * Observes retrieval state and values for [key]. + * + * Fetcher and source-of-truth failures encountered while retrieving [key] are emitted as + * [StoreResult.Error] values rather than thrown to the collector. A + * [Freshness.MustBeFresh] initial-cycle fetch or revalidation failure emits one error and + * completes the flow; every other failure leaves the flow live. Otherwise, the flow remains + * active until its collector is cancelled or the store is closed, and continues to report + * later values, including refetches triggered by invalidation and the absent-value transition + * after a clear. Concurrent collectors and callers for one key share a single fetch. + * + * [Freshness.CachedOrFetch] serves a resident value and refreshes it after invalidation; + * [Freshness.MaxAge] withholds an invalidated or over-age resident until a fetch succeeds; + * [Freshness.MustBeFresh] always withholds residence and treats an initial-cycle failure as + * terminal; [Freshness.StaleIfError] serves invalidated residence while reporting a failed + * refresh; and [Freshness.LocalOnly] never fetches. + * + * @param key the key to observe + * @param freshness the freshness policy applied to this observation + * @return a flow of loading, data, revalidation, and error results for the key + * @throws IllegalStateException if the store is closed before this call or before collection + * begins + */ + public fun stream( + key: K, + freshness: Freshness = Freshness.CachedOrFetch, + ): Flow> + + /** + * Returns the value for [key] according to [freshness]. + * + * [Freshness.CachedOrFetch] returns residence immediately and refreshes it in the background + * after invalidation. [Freshness.MaxAge] and [Freshness.MustBeFresh] block for a qualifying + * fetch. [Freshness.StaleIfError] blocks after invalidation and returns the resident value only + * when the refresh fails. [Freshness.LocalOnly] never fetches. + * + * This read is never projected by a configured overlay; overlays apply only to [stream]. + * + * @param key the key whose value is requested + * @param freshness the freshness policy applied to this read + * @return the resolved value + * @throws StoreException when no value can be returned because fetching or source-of-truth + * access failed, a concurrent [clear] removed the key while its fetch was in flight, + * [Freshness.LocalOnly] found no local value, or the server reported deletion + * ([StoreError.Missing]) + * @throws IllegalStateException if the store is already closed + */ + public suspend fun get( + key: K, + freshness: Freshness = Freshness.CachedOrFetch, + ): V + + /** + * Marks the value for [key] stale without removing it. + * + * On return, active streams of [key] have been signaled and will observe refetched data; + * the resident value is kept and served as stale in the meantime. Invalidation is + * level-triggered monotone state, so a signal issued during any race window is never lost. + * + * The stale mark is durable and survives process restart until a later successful fetch or + * revalidation clears it. + * + * @param key the key to invalidate + * @throws StoreException if persisting the stale mark fails; resident state is not signaled + * @throws IllegalStateException if the store is already closed + */ + public suspend fun invalidate(key: K) + + /** + * Marks every key in [namespace] stale without removing values. + * + * The durable namespace watermark covers keys whether or not they are currently resident. + * Resident streams are signaled on return unless a later successful fetch already superseded + * the watermark. + * + * @param namespace the namespace to invalidate + * @throws StoreException if persisting the namespace watermark fails; resident state is not + * signaled + * @throws IllegalStateException if the store is already closed + */ + public suspend fun invalidateNamespace(namespace: StoreNamespace) + + /** + * Marks every key in this store stale without removing values. + * + * The durable global watermark covers every namespace, including keys that are not currently + * resident. Resident streams are signaled on return unless a later successful fetch already + * superseded the watermark. + * + * @throws StoreException if persisting the global watermark fails; resident state is not + * signaled + * @throws IllegalStateException if the store is already closed + */ + public suspend fun invalidateAll() + + /** + * Destructively removes the value for [key]. + * + * On return, the resident value is gone: active streams observe the absent-value transition + * ([StoreResult.Loading]) and then refetched data, and an in-flight fetch that started + * before the clear can no longer commit — its waiters observe [StoreError.Missing]. + * + * Removal includes the configured source-of-truth row and its freshness bookkeeping for + * [key]. + * + * @param key the key to clear + * @throws StoreException if deleting the configured source-of-truth row fails (engine state + * remains unchanged), or if freshness cleanup fails after the row and resident state were + * removed + * @throws IllegalStateException if the store is already closed + */ + public suspend fun clear(key: K) + + /** + * Destructively removes every value in [namespace]. + * + * A scoped fence drains affected commit atoms before a resident supersede sweep, blocks new + * affected commits through the source-of-truth delete and bookkeeping cleanup, then performs + * a purge sweep. A fetcher call may finish while fenced, but its commit waits; tickets present + * before purge are superseded. While the call is in flight, an already-running read may briefly + * observe the old row and an active collector may receive one queued duplicate old [StoreResult.Data]. + * The purge closes the registry-snapshot and rehydration window before normal return. Later + * demand and writes made outside this Store remain later authority. + * + * @param namespace the namespace to clear + * @throws StoreException if durable deletion or bookkeeping cleanup fails; completed earlier + * steps remain applied and conservative + * @throws IllegalStateException if the store is already closed + */ + public suspend fun clearNamespace(namespace: StoreNamespace) + + /** + * Destructively removes every value in this store. + * + * A global fence drains every commit atom before a resident supersede sweep, blocks new commits + * through the source-of-truth delete and bookkeeping cleanup, then performs a purge sweep. A + * fetcher call may finish while fenced, but its commit waits; tickets present before purge are + * superseded. While the call is in flight, an already-running read may briefly observe the old + * row and an active collector may receive one queued duplicate old [StoreResult.Data]. The purge + * closes the registry-snapshot and rehydration window before normal return. Later demand and + * writes made outside this Store remain later authority. + * + * @throws StoreException if durable deletion or bookkeeping cleanup fails; completed earlier + * steps remain applied and conservative + * @throws IllegalStateException if the store is already closed + */ + public suspend fun clearAll() + + /** + * Releases resources owned by this store and cancels its in-flight work. + * + * Collectors and value requests waiting on in-flight work are cancelled. Subsequent calls + * to any operation fail with [IllegalStateException] and the message `Store is closed.` + * Calling `close()` more than once has no additional effect. + */ + public fun close() +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreBuilder.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreBuilder.kt new file mode 100644 index 000000000..80b922b61 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreBuilder.kt @@ -0,0 +1,192 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.core + +import org.mobilenativefoundation.store6.core.internal.DEFAULT_MAX_IDLE_ENGINES +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.internal.DefaultFreshnessValidator +import org.mobilenativefoundation.store6.core.internal.LambdaFetcher +import org.mobilenativefoundation.store6.core.internal.RealStore +import org.mobilenativefoundation.store6.core.internal.ResultFetcher +import org.mobilenativefoundation.store6.core.internal.SystemWallClock +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.FreshnessValidator +import org.mobilenativefoundation.store6.core.seam.Overlay +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry +import org.mobilenativefoundation.store6.core.seam.WallClock + +/** + * Creates a [Store] using the settings supplied by [configure]. + * + * @param K the key type accepted by the store + * @param V the non-null value type produced by the store + * @param configure configuration applied before the store is created + * @return the configured store + * @throws IllegalArgumentException if no fetcher is configured + */ +public fun store( + configure: StoreBuilder.() -> Unit, +): Store = StoreBuilder().apply(configure).build() + +/** + * Configuration receiver for the [store] creation DSL. + * + * @param K the key type accepted by the store + * @param V the non-null value type produced by the store + */ +public class StoreBuilder internal constructor() { + @OptIn(ExperimentalStoreApi::class) + private var fetcher: Fetcher? = null + + @OptIn(ExperimentalStoreApi::class) + private var sot: SourceOfTruth? = null + + @OptIn(ExperimentalStoreApi::class) + private var wallClock: WallClock = SystemWallClock + + @OptIn(ExperimentalStoreApi::class) + private var bookkeeper: Bookkeeper = InMemoryBookkeeper() + + @OptIn(ExperimentalStoreApi::class) + private var validator: FreshnessValidator = DefaultFreshnessValidator + + @OptIn(ExperimentalStoreApi::class) + private var telemetry: StoreTelemetry? = null + + @OptIn(ExperimentalStoreApi::class) + private var overlay: Overlay? = null + + private var maxIdleKeys: Int = DEFAULT_MAX_IDLE_ENGINES + + /** + * Configures the suspending function used to retrieve a value for a key. + * + * This is success-or-throw sugar for [fetcherOfResult]: a returned value becomes + * [FetcherResult.Success], while a thrown exception propagates through the store's fetch-failure + * path. The last registration wins across this function, [fetcherOfResult], and the regular- + * interface [fetcher] overload. + * + * @param fetch the function that retrieves and returns a value for the supplied key + */ + public fun fetcher(fetch: suspend (K) -> V) { + this.fetcher = LambdaFetcher(fetch) + } + + /** + * Configures a fetcher that returns the full [FetcherResult] vocabulary. + * + * The name follows v5's `Fetcher.ofResult`. The regular-interface [fetcher] overload cannot + * accept a lambda, so lambda-return-type inference remains unambiguous. The last registration + * wins across all three fetcher install points. + * + * @param fetch the function that returns a rich result for the supplied key + */ + public fun fetcherOfResult(fetch: suspend (K) -> FetcherResult) { + this.fetcher = ResultFetcher(fetch) + } + + /** + * Installs a regular-interface [Fetcher] that can receive conditional-request ETags. + * + * This overload is regular-interface-only: [Fetcher] is deliberately not a fun interface, so + * lambda calls continue to resolve to the success-or-throw [fetcher] function. The last + * registration wins across all three fetcher install points. + * + * @param fetcher the fetch source installed for this store + */ + @ExperimentalStoreApi + public fun fetcher(fetcher: Fetcher) { + this.fetcher = fetcher + } + + /** + * Bounds quiescent per-key engine residency. + * + * Engines whose key has active collectors, in-flight work, or an in-flight fetch are always + * resident and are never evicted. Once a key becomes quiescent its engine parks in an LRU idle + * set holding at most [count] engines; the eldest quiescent engine beyond the bound is destroyed. + * Eviction discards only derived in-memory state — durable rows, freshness metadata, stale marks, + * and watermarks live in the source of truth and bookkeeper, so a later read of an evicted key is + * semantically identical to one that was never evicted. `0` destroys every engine at quiescence. + * + * @param count the maximum number of quiescent engines retained; must be >= 0. Default 128. + */ + public fun maxIdleKeys(count: Int) { + require(count >= 0) { "maxIdleKeys must be >= 0, was $count." } + maxIdleKeys = count + } + + /** + * Selects the persistence seam for this store. + * + * The engine reads [SourceOfTruth.reader], writes successfully fetched values through + * [SourceOfTruth.write], and treats [SourceOfTruth.delete] as destructive persistence removal. + * When the stored selection is consumed by the engine, an absent block installs the internal + * in-memory default. Custom implementations should be validated with the source-of-truth + * contract kit. + * + * @param sot the source of truth used by the store + */ + @ExperimentalStoreApi + public fun persistence(sot: SourceOfTruth) { + this.sot = sot + } + + /** Installs a non-blocking lifecycle observer; leaving it unset preserves the null fast path. */ + @ExperimentalStoreApi + public fun telemetry(telemetry: StoreTelemetry) { + this.telemetry = telemetry + } + + /** + * Installs the stream-only projection layer for this store. + * + * The last registration wins. Leaving this unset preserves the direct-residence fast path and + * allocates no projection writer or readiness state. + */ + @ExperimentalStoreApi + public fun overlay(overlay: Overlay) { + this.overlay = overlay + } + + /** Installs the durable freshness bookkeeping implementation used by this store. */ + @ExperimentalStoreApi + public fun bookkeeper(bookkeeper: Bookkeeper) { + this.bookkeeper = bookkeeper + } + + /** Installs the wall clock used for age and freshness-bound calculations. */ + @ExperimentalStoreApi + public fun wallClock(wallClock: WallClock) { + this.wallClock = wallClock + } + + /** Installs the policy planner used to select a fetch plan for each coherent read snapshot. */ + @ExperimentalStoreApi + public fun freshnessValidator(validator: FreshnessValidator) { + this.validator = validator + } + + @OptIn(ExperimentalStoreApi::class) + internal fun build(): Store { + val fetch = requireNotNull(fetcher) { + "store { } requires a fetcher { }, fetcherOfResult { }, or " + + "fetcher(Fetcher) block." + } + val sourceOfTruth = sot ?: InMemorySourceOfTruth() + return RealStore( + fetch, + sourceOfTruth, + wallClock, + bookkeeper, + validator, + telemetry, + overlay, + maxIdleKeys, + ) + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreError.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreError.kt new file mode 100644 index 000000000..db317bbe7 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreError.kt @@ -0,0 +1,61 @@ +package org.mobilenativefoundation.store6.core + +/** + * A structured failure produced by a [Store] operation. + * + * The variant set is frozen for the 6.x major: new failure kinds map into these categories via + * their structured detail payloads, which lets the hierarchy bridge to an exhaustive Swift enum. + * Every message states what was attempted, for which key or namespace, and the likely fix. + */ +public sealed class StoreError { + /** Indicates that the configured fetcher failed to produce a value. */ + public class Fetch internal constructor( + /** What was attempted, for which key, and the likely fix. */ + public val message: String, + + /** The underlying failure, or `null` when no cause is available. */ + public val cause: Throwable?, + ) : StoreError() + + /** Indicates that a persistence operation against the store's durable state failed. */ + public class Persistence internal constructor( + /** What was attempted, for which key or namespace, and the likely fix. */ + public val message: String, + + /** The underlying failure, or `null` when no cause is available. */ + public val cause: Throwable?, + ) : StoreError() + + /** Indicates that a value could not be converted between representations. */ + public class Conversion internal constructor( + /** What was attempted, for which key, and the likely fix. */ + public val message: String, + + /** The underlying failure, or `null` when no cause is available. */ + public val cause: Throwable?, + ) : StoreError() + + /** Indicates that the requested [Freshness] policy could not be satisfied. */ + public class FreshnessUnsatisfiable internal constructor( + /** Which policy failed, for which key, and the likely fix. */ + public val message: String, + ) : StoreError() + + /** Indicates that a write conflicted with authoritative server state. */ + public class Conflict internal constructor( + /** Server-side metadata describing the conflicting state, when available. */ + public val serverMeta: StoreMeta?, + + /** What conflicted, for which key, and the likely fix. */ + public val message: String, + ) : StoreError() + + /** Indicates that no value exists for `key` and none could be produced. */ + public class Missing internal constructor( + /** The key for which no value exists. */ + public val key: StoreKey, + + /** Why the value is missing and the likely fix. */ + public val message: String, + ) : StoreError() +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreException.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreException.kt new file mode 100644 index 000000000..f47ab940a --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreException.kt @@ -0,0 +1,22 @@ +package org.mobilenativefoundation.store6.core + +/** + * Thrown by value-returning [Store] operations when no value can be returned. + * + * @param cause the underlying failure, or `null` when no cause is available + */ +public class StoreException internal constructor( + /** The structured store error represented by this exception. */ + public val error: StoreError, + cause: Throwable? = null, +) : RuntimeException(messageOf(error), cause) + +private fun messageOf(error: StoreError): String = + when (error) { + is StoreError.Fetch -> error.message + is StoreError.Persistence -> error.message + is StoreError.Conversion -> error.message + is StoreError.FreshnessUnsatisfiable -> error.message + is StoreError.Conflict -> error.message + is StoreError.Missing -> error.message + } diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreKey.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreKey.kt new file mode 100644 index 000000000..77d8e2c6f --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreKey.kt @@ -0,0 +1,25 @@ +package org.mobilenativefoundation.store6.core + +/** + * Identifies a value handled by a [Store]. + * + * A key's [namespace] and [canonicalId] together form its stable identity. + * Implementations should return the same identity components for the lifetime of the key. + */ +public interface StoreKey { + /** The logical key space containing this key. */ + public val namespace: StoreNamespace + + /** + * Returns the stable identifier for this key within [namespace]. + * + * @return an identifier that is unique within the key's namespace + */ + public fun canonicalId(): String +} + +/** A logical key space used to distinguish otherwise identical canonical identifiers. */ +public class StoreNamespace( + /** The stable name of this namespace. */ + public val value: String, +) diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreMeta.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreMeta.kt new file mode 100644 index 000000000..41d9a66c3 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreMeta.kt @@ -0,0 +1,16 @@ +package org.mobilenativefoundation.store6.core + +/** + * Typed freshness and identity metadata attached to stored values. + * + * [StoreResult.Data.age] and age-bounded [Freshness] policies derive from this metadata; an untyped + * metadata channel does not exist anywhere in Store. Milliseconds since the Unix epoch are used + * because no stable cross-platform instant type exists on the current language floor. + */ +public interface StoreMeta { + /** The wall-clock time at which the value was written, in Unix epoch milliseconds. */ + public val writtenAtEpochMillis: Long + + /** The optional entity tag associated with the value. */ + public val etag: String? +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreResult.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreResult.kt new file mode 100644 index 000000000..1a257b577 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/StoreResult.kt @@ -0,0 +1,71 @@ +package org.mobilenativefoundation.store6.core + +import kotlin.time.Duration + +/** + * A state or value reported while observing a [Store]. + * + * @param V the type of data carried by [Data] + */ +public sealed interface StoreResult { + /** Indicates that the store has no servable resident value under the policy in effect. */ + public class Loading internal constructor() : StoreResult + + /** + * A value available from the store. + * + * @param V the value type + */ + public class Data internal constructor( + /** The value produced by the store. */ + public val value: V, + + /** The source from which `value` was obtained. */ + public val origin: Origin, + + /** Elapsed time since `value` was committed to the store. */ + public val age: Duration, + + /** + * Whether the value was invalidated, or exceeds the age bound of the freshness policy in + * effect for this observation. + */ + public val isStale: Boolean, + + /** Whether a fetch was in flight for this key when this result was emitted. */ + public val refreshing: Boolean, + ) : StoreResult + + /** + * Confirmation that the current value is still fresh without a new value being produced — + * the not-modified signal of a conditional fetch. + * + * Emitted when a conditional fetch returns not-modified: the value is server-confirmed fresh, + * metadata is refreshed, and `age` is the elapsed time since the last commit measured at + * revalidation. Revalidated is a lifecycle signal: `conflateLatestData` never conflates it + * away in favor of another kind; for a blocked collector a newer `Revalidated` supersedes an + * older queued one, so the kind itself is never lost. + */ + public class Revalidated internal constructor( + /** Elapsed time since the value was last committed, measured at revalidation. */ + public val age: Duration, + ) : StoreResult + + /** + * A retrieval failure; it terminates the observed stream only in the MustBeFresh initial + * cycle, otherwise the stream stays live. + */ + public class Error internal constructor( + /** The structured error describing the failure. */ + public val error: StoreError, + + /** + * Whether a stale value was served alongside this failure under a stale-tolerant policy. + * + * `true` when an invalidated resident was served and its refresh failed under + * [Freshness.CachedOrFetch] or [Freshness.StaleIfError]; `false` when no resident was + * served or the policy withheld it. + */ + public val servedStale: Boolean, + ) : StoreResult +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Bookkeeper.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Bookkeeper.kt new file mode 100644 index 000000000..7052bb20b --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Bookkeeper.kt @@ -0,0 +1,218 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.KeyStatus + +/** + * Engine-owned metadata with deliberate identity equality. + * + * A NotModified response creates a new instance so StateFlow re-emits even when the metadata + * values match the previous successful response. + */ +internal class EngineStoreMeta( + override val writtenAtEpochMillis: Long, + override val etag: String?, +) : StoreMeta + +/** + * Volatile in-memory [Bookkeeper] with one store-local sequence for successes, marks, and + * watermarks. + * + * This implementation only simulates durable staleness while clients share this instance. Its + * semantics require retaining the instance, its per-key records, and its namespace/global + * watermarks for the store lifetime; reconstructing it loses that history. A persistent + * implementation must durably retain the same information. + * + * The sequence supports [Long.MAX_VALUE] successful sequenced operations. A subsequent attempt + * fails before mutation instead of wrapping; exhaustion is a practical-lifetime invariant + * failure, not a recoverable storage failure. + */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class InMemoryBookkeeper( + private val beforeMaintenancePublishTestGate: () -> Unit = {}, + initialSequence: Long = 0L, +) : Bookkeeper { + init { + require(initialSequence >= 0L) { "Bookkeeper sequence must not be negative" } + } + + private class Record( + val meta: StoreMeta?, + val lastSuccessSequence: Long?, + val lastFailureAtEpochMillis: Long?, + val consecutiveFailures: Int, + val staleSequence: Long?, + var cachedStatus: KeyStatus? = null, + ) + + private val lock = Mutex() + private var records = HashMap() + private var namespaceStaleWatermarks = HashMap() + private var globalStaleWatermark = 0L + private var sequence = initialSequence + private val watermarkOnlyStatus = + KeyStatus( + meta = null, + lastSuccessSequence = null, + lastFailureAtEpochMillis = null, + consecutiveFailures = 0, + durablyStale = true, + ) + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + val keyId = KeyId.from(key) + lock.withLock { + val previous = records[keyId] + val nextSequence = nextSequenceOrThrow() + val nextRecord = + Record( + meta = meta, + lastSuccessSequence = nextSequence, + lastFailureAtEpochMillis = null, + consecutiveFailures = 0, + staleSequence = previous?.staleSequence, + ) + records[keyId] = nextRecord + sequence = nextSequence + } + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + val keyId = KeyId.from(key) + lock.withLock { + val previous = records[keyId] + records[keyId] = + Record( + meta = previous?.meta, + lastSuccessSequence = previous?.lastSuccessSequence, + lastFailureAtEpochMillis = atEpochMillis, + consecutiveFailures = (previous?.consecutiveFailures ?: 0) + 1, + staleSequence = previous?.staleSequence, + ) + } + } + + override suspend fun status(key: StoreKey): KeyStatus? { + val keyId = KeyId.from(key) + return lock.withLock { + val record = records[keyId] + val coveringStaleSequence = + maxOf( + record?.staleSequence ?: 0L, + namespaceStaleWatermarks[keyId.namespace] ?: 0L, + globalStaleWatermark, + ) + if (record == null && coveringStaleSequence == 0L) { + null + } else if (record == null) { + watermarkOnlyStatus + } else { + val durablyStale = + coveringStaleSequence > (record.lastSuccessSequence ?: 0L) + record.cachedStatus + ?.takeIf { cached -> cached.durablyStale == durablyStale } + ?: KeyStatus( + meta = record.meta, + lastSuccessSequence = record.lastSuccessSequence, + lastFailureAtEpochMillis = record.lastFailureAtEpochMillis, + consecutiveFailures = record.consecutiveFailures, + durablyStale = durablyStale, + ).also { status -> + record.cachedStatus = status + } + } + } + } + + override suspend fun forget(key: StoreKey) { + val keyId = KeyId.from(key) + lock.withLock { + records.remove(keyId) + } + } + + override suspend fun markStale(key: StoreKey) { + val keyId = KeyId.from(key) + lock.withLock { + val previous = records[keyId] + val nextSequence = nextSequenceOrThrow() + val stagedRecords = copyRecords() + stagedRecords[keyId] = + Record( + meta = previous?.meta, + lastSuccessSequence = previous?.lastSuccessSequence, + lastFailureAtEpochMillis = previous?.lastFailureAtEpochMillis, + consecutiveFailures = previous?.consecutiveFailures ?: 0, + staleSequence = nextSequence, + ) + beforeMaintenancePublishTestGate() + records = stagedRecords + sequence = nextSequence + } + } + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) { + lock.withLock { + val nextSequence = nextSequenceOrThrow() + val stagedWatermarks = + HashMap(namespaceStaleWatermarks.size).also { staged -> + staged.putAll(namespaceStaleWatermarks) + staged[namespace.value] = nextSequence + } + beforeMaintenancePublishTestGate() + namespaceStaleWatermarks = stagedWatermarks + sequence = nextSequence + } + } + + override suspend fun advanceGlobalStaleWatermark() { + lock.withLock { + val nextSequence = nextSequenceOrThrow() + beforeMaintenancePublishTestGate() + globalStaleWatermark = nextSequence + sequence = nextSequence + } + } + + override suspend fun forgetNamespace(namespace: StoreNamespace) { + lock.withLock { + val stagedRecords = HashMap(records.size) + records.forEach { (key, record) -> + if (key.namespace != namespace.value) { + stagedRecords[key] = record + } + } + beforeMaintenancePublishTestGate() + records = stagedRecords + } + } + + override suspend fun forgetAll() { + lock.withLock { + val stagedRecords = HashMap() + beforeMaintenancePublishTestGate() + records = stagedRecords + } + } + + private fun copyRecords(): HashMap = + HashMap(records.size).also { staged -> staged.putAll(records) } + + private fun nextSequenceOrThrow(): Long { + check(sequence < Long.MAX_VALUE) { "Bookkeeper sequence exhausted" } + return sequence + 1L + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/EngineResidency.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/EngineResidency.kt new file mode 100644 index 000000000..b20615b62 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/EngineResidency.kt @@ -0,0 +1,24 @@ +package org.mobilenativefoundation.store6.core.internal + +/** + * Residency callbacks a [KeyEngine] uses so engine-owned background work pins its own residency. + * + * The only engine-owned work that outlives its caller's registry reference is the fetch job + * (fetch survives waiter cancellation). The job retains one reference as its first act and + * releases it after settlement, so an engine with an in-flight fetch is unevictable by + * construction and its release is the trigger for the registry's quiescence check. + */ +internal interface EngineResidencyHooks { + /** Retains one residency reference; the engine is active because the launcher holds a ref. */ + suspend fun retainFetchRef() + + /** Releases the fetch reference and runs the idle/eviction check at zero references. */ + suspend fun releaseFetchRef() + + /** No-op hooks for direct engine tests that construct a [KeyEngine] without a registry. */ + object Noop : EngineResidencyHooks { + override suspend fun retainFetchRef() = Unit + + override suspend fun releaseFetchRef() = Unit + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetchSlot.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetchSlot.kt new file mode 100644 index 000000000..ccbaa18b5 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetchSlot.kt @@ -0,0 +1,139 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import kotlin.time.Duration + +/** Describes whether a key currently owns a fetch operation. */ +internal sealed interface FetchSlot { + /** No fetch is active for the key. */ + data object Idle : FetchSlot + + /** A fetch is active and represented by [ticket]. */ + class InFlight( + val ticket: FetchTicket, + + /** The key's clear epoch when this fetch launched; a later clear supersedes the commit. */ + val clearEpochAtLaunch: Long, + ) : FetchSlot +} + +/** + * Identifies one fetch attempt and exposes its one-shot completion to all waiters. + * + * [outcome] reaches a terminal state either when the owning coroutine publishes its result or + * when the parent engine begins cancellation. + */ +@OptIn(ExperimentalStoreApi::class) +internal class FetchTicket( + val outcome: CompletableDeferred, + + /** Residence revision whose demand this ticket reserved, including an absent revision. */ + val requestRevision: Long = 0L, + + /** Residence revision used as the baseline for a NotModified response, when one existed. */ + val residenceRevisionAtLaunch: Long? = null, + + /** Exact resident envelope captured with the nullable NotModified baseline. */ + val residenceEnvelopeAtLaunch: Any? = null, + + /** Stale epoch used to plan this ticket's pre-fetch baseline. */ + val staleEpochAtLaunch: Long = 0L, + + /** Wall-clock instant used to plan this ticket's pre-fetch baseline. */ + val nowEpochMillisAtLaunch: Long = 0L, + + /** Durable bookkeeping posture used to plan this ticket's pre-fetch baseline. */ + val statusAtLaunch: KeyStatus? = null, +) { + /** + * KMP-safe early classification published with slot settlement, before ordered outcome tails. + * Reader delivery uses it to preserve ticket ownership and wake a durable causal commit row; + * [outcome] remains authoritative. + */ + val disposition = MutableStateFlow(FetchDisposition.InFlight) +} + +/** Slot-settled ticket identity visible before persistence/bookkeeping tails complete. */ +internal sealed interface FetchDisposition { + data object InFlight : FetchDisposition + + class Committing( + val attribution: AttributionTag, + /** Completed-write sequence visible before this write returned from persistence. */ + val successfulWriteSequenceAtStart: Long, + ) : FetchDisposition + + class Committed( + val successfulWriteSequence: Long, + val attribution: AttributionTag, + /** Reader generation whose raw observations were closed by this commit. */ + val rawReaderGen: Long, + /** Latest raw observation ordered before the durable commit boundary. */ + val rawCommitCutoff: Long, + /** Exact pre-return raw observation authorized after convergence, when one exists. */ + val authoritativeRawSequence: Long?, + ) : FetchDisposition + + class Revalidated( + val envelope: Any, + ) : FetchDisposition + + data object Deleted : FetchDisposition + + data object Failed : FetchDisposition + + data object Cancelled : FetchDisposition + + data object ObsoleteRevalidation : FetchDisposition + + data object Superseded : FetchDisposition +} + +/** The terminal result of a fetch attempt. */ +internal sealed interface FetchOutcome { + /** The fetched value was committed before the outcome became observable. */ + class Committed( + val value: Any, + /** Successful SoT-write sequence that became current before this outcome was published. */ + val successfulWriteSequence: Long, + /** Exact tag stamped for this commit, used to classify observations made during write. */ + val attribution: AttributionTag, + /** Reader generation whose raw observations were closed by this commit. */ + val rawReaderGen: Long, + /** Latest raw observation ordered before the durable commit boundary. */ + val rawCommitCutoff: Long, + /** Exact pre-return raw observation authorized after convergence, when one exists. */ + val authoritativeRawSequence: Long?, + ) : FetchOutcome + + /** The fetch failed with [exception] at [atEpochMillis]. */ + class Failed( + val exception: StoreException, + val atEpochMillis: Long, + val bookkeepingRecorded: Boolean = false, + ) : FetchOutcome + + /** The fetch succeeded but a clear advanced the clear epoch after launch; the value was discarded. */ + data object Superseded : FetchOutcome + + /** The fetcher reported not-modified; resident metadata was refreshed in place. */ + class Revalidated( + val residenceRevision: Long, + /** Exact refreshed envelope; same-value reader replays preserve this identity. */ + val envelope: Any, + /** Elapsed time since the value's previous commit, measured at revalidation. */ + val age: Duration, + ) : FetchOutcome + + /** A NotModified baseline changed before commit and must be planned again. */ + data object ObsoleteRevalidation : FetchOutcome + + /** The fetcher reported server-side deletion; [residenceRevision] names exact absence. */ + class Deleted( + val residenceRevision: Long, + ) : FetchOutcome +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetcherAdapters.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetcherAdapters.kt new file mode 100644 index 000000000..3a8d33ba4 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FetcherAdapters.kt @@ -0,0 +1,29 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult + +/** Adapts the public success-or-throw lambda sugar to the regular [Fetcher] interface. */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class LambdaFetcher( + private val fetch: suspend (K) -> V, +) : Fetcher { + override suspend fun fetch( + key: K, + etag: String?, + ): FetcherResult = FetcherResult.Success(fetch(key)) +} + +/** Adapts the public rich-result lambda sugar to the regular [Fetcher] interface. */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class ResultFetcher( + private val fetch: suspend (K) -> FetcherResult, +) : Fetcher { + override suspend fun fetch( + key: K, + etag: String?, + ): FetcherResult = fetch(key) +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidator.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidator.kt new file mode 100644 index 000000000..2ba32b6ba --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidator.kt @@ -0,0 +1,95 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.seam.FetchPlan +import org.mobilenativefoundation.store6.core.seam.FreshnessContext +import org.mobilenativefoundation.store6.core.seam.FreshnessValidator +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal val FetchPlan.servesResident: Boolean + get() = + when (this) { + FetchPlan.Skip -> true + is FetchPlan.Fetch -> servesResidentWhileFetching + is FetchPlan.Conditional -> servesResidentWhileFetching + } + +/** + * Returns elapsed age with the wall-clock posture: missing metadata and backward clocks + * are zero, while positive subtraction overflow saturates to [Long.MAX_VALUE] milliseconds. + */ +internal fun elapsedAge( + nowEpochMillis: Long, + meta: StoreMeta?, +): Duration { + if (meta == null) return Duration.ZERO + val writtenAtEpochMillis = meta.writtenAtEpochMillis + val elapsedMillis = + if (nowEpochMillis <= writtenAtEpochMillis) { + 0L + } else { + val delta = nowEpochMillis - writtenAtEpochMillis + if (delta < 0L) Long.MAX_VALUE else delta + } + return elapsedMillis.milliseconds +} + +/** + * The zero-configuration policy table. + * + * Negative wall-clock deltas are clamped to zero before evaluating [Freshness.MaxAge]. + */ +@OptIn(DelicateStoreApi::class) +internal object DefaultFreshnessValidator : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan = + when (val freshness = context.freshness) { + Freshness.LocalOnly -> FetchPlan.Skip + Freshness.MustBeFresh -> + context.fetchPlan(servesResidentWhileFetching = false) + Freshness.CachedOrFetch, + Freshness.StaleIfError, + -> context.cachedOrFetchPlan() + + is Freshness.MaxAge -> context.maxAgePlan(freshness) + } + + private fun FreshnessContext.cachedOrFetchPlan(): FetchPlan = + if ( + !hasResidentValue || + meta == null || + epochStale || + status?.durablyStale == true + ) { + fetchPlan(servesResidentWhileFetching = hasResidentValue) + } else { + FetchPlan.Skip + } + + private fun FreshnessContext.maxAgePlan(freshness: Freshness.MaxAge): FetchPlan { + if (!hasResidentValue) { + return fetchPlan(servesResidentWhileFetching = false) + } + + val overAge = meta == null || elapsedAge(nowEpochMillis, meta) > freshness.notOlderThan + + return if (epochStale || status?.durablyStale == true || overAge) { + fetchPlan(servesResidentWhileFetching = false) + } else { + FetchPlan.Skip + } + } + + private fun FreshnessContext.fetchPlan(servesResidentWhileFetching: Boolean): FetchPlan { + val etag = meta?.etag + return if (hasResidentValue && etag != null) { + FetchPlan.Conditional(etag, servesResidentWhileFetching) + } else { + FetchPlan.Fetch(servesResidentWhileFetching) + } + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruth.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruth.kt new file mode 100644 index 000000000..9f3402b49 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruth.kt @@ -0,0 +1,90 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +/** + * DSL-default source of truth that keeps the engine on one persistence path with or without a + * caller-supplied implementation. + * + * Canonical-key cells are intentionally unbounded. + */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class InMemorySourceOfTruth : SourceOfTruth { + private class Cell( + val row: V?, + val version: Long, + ) + + private val lock = Mutex() + private val cells = HashMap>>() + + override fun reader(key: K): Flow = + flow { + emitAll(cellFor(key).map { it.row }) + } + + override suspend fun write( + key: K, + value: V, + ) { + update(key, value) + } + + override suspend fun delete(key: K) { + update(key, null) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + lock.withLock { + cells.forEach { (keyId, cell) -> + if (keyId.namespace == namespace.value) { + cell.emitNull() + } + } + } + } + + override suspend fun deleteAll() { + lock.withLock { + cells.values.forEach { cell -> cell.emitNull() } + } + } + + private suspend fun cellFor(key: K): MutableStateFlow> { + val keyId = KeyId.from(key) + return lock.withLock { cellFor(keyId) } + } + + private suspend fun update( + key: K, + row: V?, + ) { + val keyId = KeyId.from(key) + lock.withLock { + val cell = cellFor(keyId) + val current = cell.value + cell.value = Cell(row = row, version = current.version + 1L) + } + } + + private fun cellFor(keyId: KeyId): MutableStateFlow> = + cells.getOrPut(keyId) { + MutableStateFlow(Cell(row = null, version = 0L)) + } + + private fun MutableStateFlow>.emitNull() { + val current = value + value = Cell(row = null, version = current.version + 1L) + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEngine.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEngine.kt new file mode 100644 index 000000000..7c894c6c7 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEngine.kt @@ -0,0 +1,5166 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.FetchPlan +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.FreshnessContext +import org.mobilenativefoundation.store6.core.seam.FreshnessValidator +import org.mobilenativefoundation.store6.core.seam.KeyEvents +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.core.seam.Overlay +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry +import org.mobilenativefoundation.store6.core.seam.WallClock +import kotlin.time.Duration +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** Emits every stale epoch that advanced beyond the snapshot used to plan a stream startup. */ +internal fun Flow.staleEpochsAfter(planningEpoch: Long): Flow = + map { it.staleEpoch } + .distinctUntilChanged() + .filter { observedEpoch -> observedEpoch > planningEpoch } + +/** + * Coordinates one canonical key around a single shared source-of-truth reader pipeline. + * + * [writeLock] serializes persistence mutations and ordered bookkeeping. [stateLock] protects the + * immutable state snapshot, residence, and its monotone revision. When both locks are needed the + * order is always writeLock then stateLock; no lock is held across fetcher I/O. + */ +@OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +internal class KeyEngine( + internal val key: K, + private val keyId: KeyId, + private val fetcher: Fetcher, + private val sot: SourceOfTruth, + private val bookkeeper: Bookkeeper, + private val validator: FreshnessValidator, + private val wallClock: WallClock, + private val engineScope: CoroutineScope, + private val residencyHooks: EngineResidencyHooks = EngineResidencyHooks.Noop, + /** Optional deterministic gate used only by direct engine tests before final initial recapture. */ + private val beforeInitialDeliveryTestGate: suspend () -> Unit = {}, + /** Optional direct-test gate after the initial planning snapshot, before outcome classification. */ + private val afterInitialPlanningSnapshotTestGate: suspend () -> Unit = {}, + /** Optional deterministic gate used only by direct engine tests after raw reader observation. */ + private val beforeReaderRecordMappingTestGate: suspend () -> Unit = {}, + /** Optional direct-test gate after mapping but before serialized reader delivery. */ + private val beforeReaderDeliveryLockTestGate: suspend (ReaderRecord) -> Unit = {}, + /** Optional deterministic gate used only by direct engine tests inside serialized delivery. */ + private val beforeReaderDeliveryTestGate: suspend () -> Unit = {}, + /** Optional deterministic gate used only by direct engine tests before outcome delivery. */ + private val beforeTicketOutcomeDeliveryTestGate: suspend () -> Unit = {}, + /** Optional deterministic gate before first classification of a replacement disposition. */ + private val beforeReplacementDispositionClassificationTestGate: suspend () -> Unit = {}, + /** Store-local fence shared by RealStore; direct tests receive an isolated coordinator. */ + private val maintenanceCoordinator: MaintenanceCoordinator = MaintenanceCoordinator(), + /** Null keeps every telemetry hook and fetch-duration allocation off the unconfigured path. */ + private val telemetry: StoreTelemetry? = null, + /** Store-level advisory bus; direct engine tests receive an isolated equivalent by default. */ + private val events: MutableSharedFlow = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ), + /** Null preserves the direct-residence path without projection allocations. */ + private val overlay: Overlay? = null, + /** Deterministic direct-test gate after Pending and before invoking Overlay.apply. */ + private val beforeProjectionApplyTestGate: suspend (V?) -> Unit = {}, + /** Deterministic direct-test gate after Overlay.apply and before the commit recheck. */ + private val afterProjectionApplyTestGate: suspend (V?) -> Unit = {}, + /** Deterministic direct-test gate before serialized projection snapshot delivery. */ + private val beforeProjectionDeliveryLockTestGate: suspend () -> Unit = {}, + /** Deterministic direct-test gate inside serialized projection snapshot delivery. */ + private val beforeProjectionDeliveryTestGate: suspend () -> Unit = {}, + /** Deterministic direct-test gate after serialized projection snapshot delivery. */ + private val afterProjectionDeliveryTestGate: suspend () -> Unit = {}, + /** Deterministic direct-test gate before a readiness waiter suspends on both state flows. */ + private val beforeProjectionReadinessWaitTestGate: suspend () -> Unit = {}, + /** Deterministic direct-test gate after coherent base capture and before authorization. */ + private val beforeProjectionAuthorizationTestGate: suspend () -> Unit = {}, +) { + private val stateLock = Mutex() + private val writeLock = Mutex() + private val engineJob: Job = checkNotNull(engineScope.coroutineContext[Job]) + private val closeSignal: Job = Job(engineJob) + + private val mutableState = MutableStateFlow(KeyState.Initial) + internal val state: StateFlow = mutableState.asStateFlow() + + private val residence = MutableStateFlow?>(null) + + /** Changed only by [replaceResidenceLocked] while stateLock is held. */ + private var residenceRevision: Long = 0L + + /** Exists only for configured engines and immediately obsoletes revision-bound waiters. */ + private val projectionResidence: MutableStateFlow>? = + overlay?.let { + MutableStateFlow( + ProjectionResidence( + ProjectionBase( + envelope = null, + revision = residenceRevision, + ), + ), + ) + } + + /** Latest single-writer state; null is the allocation-free unconfigured path. */ + private val projectionSnapshot: MutableStateFlow>? = + overlay?.let { MutableStateFlow(ProjectionSnapshot.Uninitialized) } + + /** Advanced only by the projection writer while publishing Pending or Terminal. */ + private var projectionGeneration: Long = 0L + + /** Lock-serialized source-order boundary for raw observations and active SoT writes. */ + private val writeObservationBoundary = + MutableStateFlow( + WriteObservationBoundary( + readerGen = 0L, + observedAttribution = null, + activeAttribution = null, + successfulSequence = 0L, + latestRawSequence = 0L, + activeRawPhase = ActiveRawPhase.Unobserved, + readerSession = 0L, + readerSessionActive = false, + pendingWriteAttribution = null, + ), + ) + + /** Latest durable resolution of raw observations ordered before a successful write return. */ + private var rawCommitResolution: RawCommitResolution? = null + + /** Completion fence for a destructive persistence mutation; guarded by [stateLock]. */ + private var destructiveMutationBarrier: CompletableDeferred? = null + + /** + * One retrying adapter pipeline shared by every active collector for this key. + * + * Adapter invocation and collection failures are converted before engine mapping. A mapping + * or transition defect therefore remains fatal instead of being mislabeled and retried as a + * persistence outage. + */ + private val readerRecords: SharedFlow> = + state + .map { it.readerGen } + .distinctUntilChanged() + .flatMapLatest { readerGen -> + var failureReportedForEpisode = false + flow { + val readerSession = beginRawReaderSession(readerGen) + try { + emitAll(sot.reader(key)) + } finally { + endRawReaderSession(readerGen, readerSession) + } + } + .map> { value -> + failureReportedForEpisode = false + try { + rawReaderRow(readerGen, value) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + throw RawObservationFailure(failure) + } + } + .conflate() + .onCompletion { cause -> + if (cause == null) { + error( + "SourceOfTruth.reader completed normally for key " + + "'${keyId.namespace}/${keyId.canonicalId}'.", + ) + } + } + .retryWhen { failure, _ -> + if (failure is CancellationException) throw failure + if (failure is RawObservationFailure) throw failure.engineFailure + if (!failureReportedForEpisode) { + emit(RawReaderEvent.Failure(readerException(failure))) + failureReportedForEpisode = true + } + delay(READER_RETRY_DELAY_MILLIS) + true + } + .mapNotNull { event -> + when (event) { + is RawReaderEvent.Row -> { + beforeReaderRecordMappingTestGate() + toRecord(readerGen, event) + } + is RawReaderEvent.Failure -> + readerFailureRecord(readerGen, event.exception) + } + } + .retryWhen { failure, _ -> + if (failure is RestartRawReaderSession) { + true + } else { + throw failure + } + } + } + .shareIn( + scope = engineScope, + started = + SharingStarted.WhileSubscribed( + stopTimeoutMillis = READER_PIPELINE_GRACE_MILLIS, + replayExpirationMillis = 0L, + ), + replay = 1, + ) + + init { + overlay?.let { configured -> + engineScope.launch { runProjectionWriter(configured) } + } + } + + /** Quiescent for idling: no fetch owns the slot. All other work holds registry references. */ + internal fun isQuiescentForIdle(): Boolean = state.value.fetch is FetchSlot.Idle + + /** Destroys a quiescent, unreferenced engine; nothing user-visible is running by definition. */ + internal fun destroy() { + engineScope.cancel(CancellationException(ENGINE_EVICTED_MESSAGE)) + } + + /** Assigns residence and advances its revision for every accepted observation or mutation. */ + private fun replaceResidenceLocked( + envelope: ValueEnvelope?, + preserveProjectionAuthorizationLineage: Boolean = false, + ): Long { + val nextProjectionAuthorizationLineage = + projectionResidence?.let { projection -> + when { + envelope == null -> null + preserveProjectionAuthorizationLineage -> + checkNotNull(projection.value.base.authorizationLineage) { + "A metadata successor requires an existing projection lineage." + } + else -> ProjectionAuthorizationLineage() + } + } + residence.value = envelope + residenceRevision += 1L + projectionResidence?.value = + ProjectionResidence( + ProjectionBase( + envelope = envelope, + revision = residenceRevision, + authorizationLineage = nextProjectionAuthorizationLineage, + ), + ) + return residenceRevision + } + + /** Returns the configured projection base only when it names this exact residence snapshot. */ + private fun projectionBaseLocked( + envelope: ValueEnvelope?, + revision: Long, + ): ProjectionBase? = + projectionResidence?.value?.base?.takeIf { base -> + base.envelope === envelope && base.revision == revision + } + + /** Serially accepts residence and overlay triggers and computes outside every Store lock. */ + private suspend fun runProjectionWriter(configured: Overlay) { + val residenceTriggers = checkNotNull(projectionResidence).map { Unit } + val overlayTriggers = + configured.changes + .filter { changed -> KeyId.from(changed) == keyId } + .map { Unit } + .catch { failure -> + // Downstream/parent cancellation stays transparent, preserving an apply failure. + if (failure is CancellationException && engineJob.isActive) { + throw ProjectionChangesFailure(failure) + } + throw failure + } + try { + merge(residenceTriggers, overlayTriggers).collect { + val pending = + stateLock.withLock { + val base = checkNotNull(projectionResidence).value.base + projectionGeneration += 1L + ProjectionSnapshot.Pending( + base = base, + generation = projectionGeneration, + ).also { projection -> + checkNotNull(projectionSnapshot).value = projection + } + } + val baseValue = pending.base.envelope?.value + beforeProjectionApplyTestGate(baseValue) + val output = configured.apply(key, baseValue) + afterProjectionApplyTestGate(baseValue) + val projection = + when { + pending.base.envelope != null && + output == pending.base.envelope.value -> + Projection.Value(pending.base.envelope) + + output == null -> Projection.Absent + else -> Projection.Overlaid(output) + } + stateLock.withLock { + val currentResidence = checkNotNull(projectionResidence).value.base + val currentSnapshot = checkNotNull(projectionSnapshot).value + if ( + currentResidence.matches(pending.base) && + currentSnapshot is ProjectionSnapshot.Pending && + currentSnapshot.generation == pending.generation + ) { + projectionSnapshot.value = + ProjectionSnapshot.Ready( + base = pending.base, + generation = pending.generation, + projection = projection, + ) + } + } + } + } catch (failure: Throwable) { + if (failure is CancellationException && !engineJob.isActive) throw failure + val terminalFailure = + (failure as? ProjectionChangesFailure)?.projectionCause ?: failure + stateLock.withLock { + projectionGeneration += 1L + checkNotNull(projectionSnapshot).value = + ProjectionSnapshot.Terminal( + generation = projectionGeneration, + failure = terminalFailure, + ) + } + } + } + + /** Opens one upstream reader session and retires any fence from a cancelled predecessor. */ + private fun beginRawReaderSession(readerGen: Long): Long { + val opened = + updateWriteObservationBoundary { current -> + if (current.readerGen != readerGen) { + current + } else { + val nextSession = current.readerSession + 1L + current.copy( + readerSession = nextSession, + readerSessionActive = true, + pendingWriteAttribution = null, + ) + } + } + return opened.readerSession + } + + /** Retires only the matching session; a newer reader must keep its own boundary state. */ + private fun endRawReaderSession( + readerGen: Long, + readerSession: Long, + ) { + updateWriteObservationBoundary { current -> + if (current.readerGen == readerGen && current.readerSession == readerSession) { + current.copy( + readerSessionActive = false, + pendingWriteAttribution = null, + ) + } else { + current + } + } + } + + /** Captures source order and active-write provenance under [stateLock] before conflation. */ + private suspend fun rawReaderRow( + readerGen: Long, + value: V?, + ): RawReaderEvent.Row = + stateLock.withLock { captureRawReaderRowLocked(readerGen, value) } + + /** Allocates one raw token; the return-boundary CAS may race but mapping cannot. */ + private fun captureRawReaderRowLocked( + readerGen: Long, + value: V?, + ): RawReaderEvent.Row { + while (true) { + val current = writeObservationBoundary.value + if (current.readerGen != readerGen) { + return RawReaderEvent.Row( + value = value, + readerGen = readerGen, + rawObservationSequence = current.latestRawSequence, + attributionAtObservation = current.observedAttribution, + successfulWriteSequenceAtObservation = current.successfulSequence, + activeWriteAttributionAtObservation = current.activeAttribution, + followedMatchingActiveWriteRow = false, + pendingCommitFenceAtObservation = false, + ) + } + + val nextSequence = current.latestRawSequence + 1L + // A live reader can have pre-return notifications queued upstream. The exact + // writer-current closes that fence; observations after it are later authority. + val pendingWriteAttribution = current.pendingWriteAttribution + val activeWriteAttribution = current.activeAttribution + val activeAttributionAtObservation = + when { + pendingWriteAttribution == null -> activeWriteAttribution + value != null && pendingWriteAttribution.value == value -> + pendingWriteAttribution + value != null && activeWriteAttribution?.value == value -> + activeWriteAttribution + else -> pendingWriteAttribution + } + val observation = + RawWriteObservation( + readerGen = readerGen, + rawSequence = nextSequence, + value = value, + attributionAtObservation = current.observedAttribution, + activeWriteAttributionAtObservation = activeAttributionAtObservation, + successfulWriteSequenceAtObservation = current.successfulSequence, + ) + val activeObservation = + if (activeAttributionAtObservation === activeWriteAttribution) { + observation + } else { + observation.copy( + activeWriteAttributionAtObservation = activeWriteAttribution, + ) + } + val matchingActiveAttribution = activeObservation.matchingWriterAttribution() + val followedMatchingActiveWriteRow = + activeWriteAttribution != null && + matchingActiveAttribution == null && + (current.activeRawPhase is ActiveRawPhase.Matching || + current.activeRawPhase is ActiveRawPhase.OtherAfterMatching) + val nextPhase = + if (activeWriteAttribution == null) { + current.activeRawPhase + } else if (matchingActiveAttribution != null) { + ActiveRawPhase.Matching(activeObservation, matchingActiveAttribution) + } else { + when (current.activeRawPhase) { + is ActiveRawPhase.Matching -> + ActiveRawPhase.OtherAfterMatching( + matchingObservation = current.activeRawPhase.observation, + observation = activeObservation, + ) + + is ActiveRawPhase.OtherAfterMatching -> + ActiveRawPhase.OtherAfterMatching( + matchingObservation = + current.activeRawPhase.matchingObservation, + observation = activeObservation, + ) + + ActiveRawPhase.Unobserved, + is ActiveRawPhase.OtherBeforeMatching, + -> ActiveRawPhase.OtherBeforeMatching(activeObservation) + } + } + val activeExactSupersedesPending = + value != null && + activeWriteAttribution != null && + activeWriteAttribution.value == value + val updated = + current.copy( + latestRawSequence = nextSequence, + activeRawPhase = nextPhase, + pendingWriteAttribution = + if ( + value != null && + (pendingWriteAttribution?.value == value || + activeExactSupersedesPending) + ) { + null + } else { + pendingWriteAttribution + }, + ) + if (writeObservationBoundary.compareAndSet(current, updated)) { + return RawReaderEvent.Row( + value = value, + readerGen = readerGen, + rawObservationSequence = nextSequence, + attributionAtObservation = observation.attributionAtObservation, + successfulWriteSequenceAtObservation = + observation.successfulWriteSequenceAtObservation, + activeWriteAttributionAtObservation = + observation.activeWriteAttributionAtObservation, + followedMatchingActiveWriteRow = followedMatchingActiveWriteRow, + pendingCommitFenceAtObservation = + pendingWriteAttribution != null && + activeAttributionAtObservation === pendingWriteAttribution, + ) + } + } + } + + /** Mirrors lock-owned state into the source-order boundary while [stateLock] is held. */ + private fun syncObservedAttributionLocked(state: KeyState) { + val generationChanged = writeObservationBoundary.value.readerGen != state.readerGen + if (generationChanged) { + rawCommitResolution = null + } + updateWriteObservationBoundary { current -> + if (current.readerGen == state.readerGen) { + current.copy(observedAttribution = state.attribution) + } else { + current.copy( + readerGen = state.readerGen, + observedAttribution = state.attribution, + activeAttribution = null, + activeRawPhase = ActiveRawPhase.Unobserved, + readerSessionActive = false, + pendingWriteAttribution = null, + ) + } + } + } + + /** Applies one CAS-loop boundary update and preserves concurrent raw observations. */ + private inline fun updateWriteObservationBoundary( + transform: (WriteObservationBoundary) -> WriteObservationBoundary, + ): WriteObservationBoundary { + while (true) { + val current = writeObservationBoundary.value + val updated = transform(current) + if (writeObservationBoundary.compareAndSet(current, updated)) return updated + } + } + + /** Applies one pure event while serializing the state swap. */ + private suspend fun applyEvent(event: KeyEvent): KeyEffect = + stateLock.withLock { + val result = transition(mutableState.value, event) + mutableState.value = result.state + syncObservedAttributionLocked(result.state) + result.effect + } + + /** Maps a reader row only after any exact writer attribution becomes durably committed. */ + private suspend fun toRecord( + readerGen: Long, + event: RawReaderEvent.Row, + ): ReaderRecord? { + var prepared: PreparedReaderRow? = null + var decided = false + val immediate = + stateLock.withLock { + val snapshot = mutableState.value + if (snapshot.readerGen != readerGen) return@withLock null + if (isSupersededRawObservation(event)) { + decided = true + return@withLock null + } + rawCommitResolution + ?.takeIf { + it.readerGen == readerGen && + event.rawObservationSequence <= it.rawCommitCutoff + } + ?.let { resolution -> + decided = true + return@withLock if ( + event.rawObservationSequence == + resolution.authoritativeRawSequence + ) { + recordFromConvergedRawLocked(event, resolution) + ?: recordForConfirmFreshAdvancedEnvelopeLocked( + event = event, + resolution = resolution, + consumedAttributionOverride = null, + ) + } else { + null + } + } + // A fenced mismatch is ambiguous with a notification queued before the + // writer-current. It cannot map directly; a committed owner replaces the reader + // so that only the new session's current row can establish later authority. + if (event.pendingCommitFenceAtObservation) { + val ownerAttribution = + checkNotNull(event.activeWriteAttributionAtObservation) + val matchingAttribution = + ownerAttribution.takeIf { + event.value != null && it.value == event.value + } + when (val disposition = ownerAttribution.owner.disposition.value) { + is FetchDisposition.Committed -> { + decided = true + return@withLock if ( + matchingAttribution != null && + disposition.attribution === ownerAttribution + ) { + recordForExactWriterEnvelopeLocked( + event = event, + attribution = ownerAttribution, + consumedAttribution = null, + ) ?: recordForCurrentSameValueEnvelopeLocked( + event = event, + consumedAttribution = null, + ) + } else { + if ( + matchingAttribution == null && + disposition.attribution === ownerAttribution && + pendingFenceStillActiveLocked(event, ownerAttribution) + ) { + throw RestartRawReaderSession() + } + null + } + } + + FetchDisposition.InFlight, + is FetchDisposition.Committing, + -> { + prepared = + PreparedReaderRow( + consumedAttribution = null, + ownerAttribution = ownerAttribution, + matchingAttribution = matchingAttribution, + dropNonmatchingOnCommit = true, + ) + return@withLock null + } + + else -> { + decided = true + return@withLock null + } + } + } + + val consumed = + transition( + snapshot, + KeyEvent.ConsumeAttribution(event.attributionAtObservation), + ) + mutableState.value = consumed.state + syncObservedAttributionLocked(consumed.state) + val tag = (consumed.effect as KeyEffect.Consumed).tag + val value = event.value + val matchingAttribution = + value?.let { + when { + tag?.value == value -> tag + tag == null && + event.activeWriteAttributionAtObservation?.value == value -> + event.activeWriteAttributionAtObservation + else -> null + } + } + val activeAttribution = event.activeWriteAttributionAtObservation + val activeDisposition = activeAttribution?.owner?.disposition?.value + val postReturnOrPostMatchProvisional = + matchingAttribution == null && + activeDisposition is FetchDisposition.Committing && + activeDisposition.attribution === activeAttribution && + (event.successfulWriteSequenceAtObservation > + activeDisposition.successfulWriteSequenceAtStart || + event.followedMatchingActiveWriteRow) + if (postReturnOrPostMatchProvisional) { + prepared = + PreparedReaderRow( + consumedAttribution = tag, + ownerAttribution = checkNotNull(activeAttribution), + matchingAttribution = null, + dropNonmatchingOnCommit = false, + ) + null + } else if (matchingAttribution == null) { + decided = true + mapReaderRowLocked( + readerGen = readerGen, + event = event, + tag = tag, + matchingAttribution = null, + ) + } else { + when (val disposition = matchingAttribution.owner.disposition.value) { + is FetchDisposition.Committed -> { + decided = true + if (disposition.attribution !== matchingAttribution) { + null + } else { + recordForExactWriterEnvelopeLocked( + event = event, + attribution = matchingAttribution, + consumedAttribution = tag, + ) + } + } + + FetchDisposition.InFlight, + is FetchDisposition.Committing, + -> { + prepared = + PreparedReaderRow( + consumedAttribution = tag, + ownerAttribution = matchingAttribution, + matchingAttribution = matchingAttribution, + dropNonmatchingOnCommit = false, + ) + null + } + + else -> { + decided = true + null + } + } + } + } + if (decided) return immediate + val provisionalRow = prepared ?: return immediate + + val ownerAttribution = provisionalRow.ownerAttribution + val matchingAttribution = provisionalRow.matchingAttribution + val owner = ownerAttribution.owner + val disposition = + when (val current = owner.disposition.value) { + FetchDisposition.InFlight, + is FetchDisposition.Committing, + -> + owner.disposition.first { candidate -> + candidate !== FetchDisposition.InFlight && + candidate !is FetchDisposition.Committing + } + + else -> current + } + val committed = disposition as? FetchDisposition.Committed + if (committed != null && committed.attribution !== ownerAttribution) { + return null + } + val restartFencedMismatch = + committed != null && + provisionalRow.dropNonmatchingOnCommit && + matchingAttribution == null + val writeDidNotCommit = + disposition === FetchDisposition.Failed || + disposition === FetchDisposition.Cancelled + if (committed == null && (!writeDidNotCommit || matchingAttribution != null)) return null + // A terminal failed owner cannot lend its consumed tag to the resumed row. With no tag, + // an equal live predecessor envelope stays unchanged while different content remains SOT. + val retainedConsumedAttribution = + provisionalRow.consumedAttribution.takeUnless { writeDidNotCommit } + + return stateLock.withLock { + if (mutableState.value.readerGen != readerGen) return@withLock null + if (isSupersededRawObservation(event)) return@withLock null + rawCommitResolution + ?.takeIf { + it.readerGen == readerGen && + event.rawObservationSequence <= it.rawCommitCutoff + } + ?.let { resolution -> + return@withLock if ( + event.rawObservationSequence == resolution.authoritativeRawSequence + ) { + recordFromConvergedRawLocked( + event = event, + resolution = resolution, + consumedAttributionOverride = + retainedConsumedAttribution, + ) ?: recordForConfirmFreshAdvancedEnvelopeLocked( + event = event, + resolution = resolution, + consumedAttributionOverride = + retainedConsumedAttribution, + ) + } else { + null + } + } + if (restartFencedMismatch) { + if (pendingFenceStillActiveLocked(event, ownerAttribution)) { + throw RestartRawReaderSession() + } + return@withLock null + } + if (matchingAttribution != null) { + recordForExactWriterEnvelopeLocked( + event = event, + attribution = matchingAttribution, + consumedAttribution = retainedConsumedAttribution, + ) ?: if (provisionalRow.dropNonmatchingOnCommit) { + recordForCurrentSameValueEnvelopeLocked( + event = event, + consumedAttribution = retainedConsumedAttribution, + ) + } else { + null + } + } else { + mapReaderRowLocked( + readerGen = readerGen, + event = event, + tag = retainedConsumedAttribution, + matchingAttribution = null, + ) + } + } + } + + /** True only while this event still belongs to the unresolved live-session fence. */ + private fun pendingFenceStillActiveLocked( + event: RawReaderEvent.Row, + attribution: AttributionTag, + ): Boolean { + val boundary = writeObservationBoundary.value + return boundary.readerGen == event.readerGen && + boundary.readerSessionActive && + boundary.pendingWriteAttribution === attribution + } + + /** True when conflate has already observed a newer row/absence in this reader generation. */ + private fun isSupersededRawObservation(event: RawReaderEvent.Row): Boolean { + val boundary = writeObservationBoundary.value + return boundary.readerGen == event.readerGen && + boundary.latestRawSequence > event.rawObservationSequence + } + + /** Reuses commit-side convergence without mutating residence or advancing its revision. */ + private fun recordFromConvergedRawLocked( + event: RawReaderEvent.Row, + resolution: RawCommitResolution, + consumedAttributionOverride: AttributionTag? = null, + ): ReaderRecord? { + if (residenceRevision != resolution.residenceRevision) return null + if (residence.value !== resolution.envelope) return null + val value = event.value + return if (value == null) { + if (resolution.envelope != null) return null + ReaderRecord.Absent( + readerGen = event.readerGen, + residenceRevision = residenceRevision, + successfulWriteSequenceAtObservation = + event.successfulWriteSequenceAtObservation, + consumedAttribution = + consumedAttributionOverride ?: resolution.consumedAttribution, + activeWriteAttributionAtObservation = + event.activeWriteAttributionAtObservation, + rawObservationSequence = event.rawObservationSequence, + ) + } else { + val envelope = resolution.envelope ?: return null + if (envelope.value != value) return null + ReaderRecord.Row( + envelope = envelope, + readerGen = event.readerGen, + residenceRevision = residenceRevision, + successfulWriteSequenceAtObservation = + event.successfulWriteSequenceAtObservation, + consumedAttribution = + consumedAttributionOverride ?: resolution.consumedAttribution, + activeWriteAttributionAtObservation = + event.activeWriteAttributionAtObservation, + rawObservationSequence = event.rawObservationSequence, + ) + } + } + + /** Reuses only a confirmFresh-advanced envelope for the exact committed raw writer token. */ + private fun recordForConfirmFreshAdvancedEnvelopeLocked( + event: RawReaderEvent.Row, + resolution: RawCommitResolution, + consumedAttributionOverride: AttributionTag?, + ): ReaderRecord.Row? { + val value = event.value ?: return null + val committedEnvelope = resolution.envelope ?: return null + val currentEnvelope = residence.value ?: return null + if (residenceRevision <= resolution.residenceRevision) return null + if (currentEnvelope === committedEnvelope) return null + + val attribution = event.activeWriteAttributionAtObservation ?: return null + val disposition = + attribution.owner.disposition.value as? FetchDisposition.Committed ?: return null + if (disposition.attribution !== attribution) return null + if (!committedEnvelope.matchesWriterAttribution(value, attribution)) return null + + if (currentEnvelope.value != value) return null + if (currentEnvelope.origin != committedEnvelope.origin) return null + if (currentEnvelope.meta == null || currentEnvelope.meta === committedEnvelope.meta) { + return null + } + if (currentEnvelope.staleEpochAtCommit < committedEnvelope.staleEpochAtCommit) return null + if (currentEnvelope.directRevalidationOwner != null) return null + + return ReaderRecord.Row( + envelope = currentEnvelope, + readerGen = event.readerGen, + residenceRevision = residenceRevision, + successfulWriteSequenceAtObservation = + event.successfulWriteSequenceAtObservation, + consumedAttribution = + consumedAttributionOverride ?: resolution.consumedAttribution, + activeWriteAttributionAtObservation = + event.activeWriteAttributionAtObservation, + rawObservationSequence = event.rawObservationSequence, + ) + } + + /** Returns the exact already-installed writer envelope, avoiding a duplicate revision bump. */ + private fun recordForExactWriterEnvelopeLocked( + event: RawReaderEvent.Row, + attribution: AttributionTag, + consumedAttribution: AttributionTag?, + ): ReaderRecord.Row? { + val value = event.value ?: return null + val envelope = residence.value ?: return null + if (!envelope.matchesWriterAttribution(value, attribution)) return null + return ReaderRecord.Row( + envelope = envelope, + readerGen = event.readerGen, + residenceRevision = residenceRevision, + successfulWriteSequenceAtObservation = + event.successfulWriteSequenceAtObservation, + consumedAttribution = consumedAttribution, + activeWriteAttributionAtObservation = event.activeWriteAttributionAtObservation, + rawObservationSequence = event.rawObservationSequence, + ) + } + + /** Reuses the live same-value envelope for a fenced exact row after residence advancement. */ + private fun recordForCurrentSameValueEnvelopeLocked( + event: RawReaderEvent.Row, + consumedAttribution: AttributionTag?, + ): ReaderRecord.Row? { + val value = event.value ?: return null + val envelope = residence.value ?: return null + if (envelope.value != value) return null + return ReaderRecord.Row( + envelope = envelope, + readerGen = event.readerGen, + residenceRevision = residenceRevision, + successfulWriteSequenceAtObservation = + event.successfulWriteSequenceAtObservation, + consumedAttribution = consumedAttribution, + activeWriteAttributionAtObservation = event.activeWriteAttributionAtObservation, + rawObservationSequence = event.rawObservationSequence, + ) + } + + private fun ValueEnvelope.matchesWriterAttribution( + value: Any, + attribution: AttributionTag, + ): Boolean = + this.value == value && + origin == attribution.origin && + meta === attribution.meta && + staleEpochAtCommit == attribution.staleEpochAtCommit && + directRevalidationOwner == null + + /** Installs one already-authorized adapter observation while [stateLock] is held. */ + private fun mapReaderRowLocked( + readerGen: Long, + event: RawReaderEvent.Row, + tag: AttributionTag?, + matchingAttribution: AttributionTag?, + ): ReaderRecord { + val value = event.value + val record = if (value == null) { + val revision = replaceResidenceLocked(null) + ReaderRecord.Absent( + readerGen = readerGen, + residenceRevision = revision, + successfulWriteSequenceAtObservation = + event.successfulWriteSequenceAtObservation, + consumedAttribution = tag, + activeWriteAttributionAtObservation = + event.activeWriteAttributionAtObservation, + rawObservationSequence = event.rawObservationSequence, + ) + } else { + val current = residence.value + val envelope = + when { + matchingAttribution != null -> + ValueEnvelope( + value = value, + origin = matchingAttribution.origin, + meta = matchingAttribution.meta, + staleEpochAtCommit = matchingAttribution.staleEpochAtCommit, + ) + + tag != null -> + ValueEnvelope( + value = value, + origin = Origin.SOT, + meta = null, + staleEpochAtCommit = mutableState.value.staleEpoch, + ) + + current != null && current.value == value -> current + + else -> + ValueEnvelope( + value = value, + origin = Origin.SOT, + meta = null, + staleEpochAtCommit = mutableState.value.staleEpoch, + ) + } + val revision = replaceResidenceLocked(envelope) + ReaderRecord.Row( + envelope = envelope, + readerGen = readerGen, + residenceRevision = revision, + successfulWriteSequenceAtObservation = + event.successfulWriteSequenceAtObservation, + consumedAttribution = tag, + activeWriteAttributionAtObservation = + event.activeWriteAttributionAtObservation, + rawObservationSequence = event.rawObservationSequence, + ) + } + return record + } + + /** Converts one adapter outage into a generation-bound record without changing residence. */ + private suspend fun readerFailureRecord( + readerGen: Long, + exception: StoreException, + ): ReaderRecord? = + stateLock.withLock { + if (mutableState.value.readerGen != readerGen) return@withLock null + ReaderRecord.Failure(exception, readerGen, residenceRevision) + } + + /** Waits out destructive mutation tails, then resolves a queued record against live state. */ + private suspend fun resolveCurrentRecord(record: ReaderRecord): ReaderResolution? { + while (true) { + val status = bookkeeper.status(key) + var barrier: CompletableDeferred? = null + val resolved = + stateLock.withLock { + barrier = destructiveMutationBarrier + if (barrier == null) { + resolveCurrentRecord( + record = record, + currentReaderGen = mutableState.value.readerGen, + currentResidence = residence.value, + currentResidenceRevision = residenceRevision, + )?.let { current -> + ReaderResolution( + record = current, + state = mutableState.value, + status = status, + nowEpochMillis = wallClock.nowEpochMillis(), + projectionBase = + when (current) { + is ReaderRecord.Row -> + projectionBaseLocked( + current.envelope, + current.residenceRevision, + ) + is ReaderRecord.Absent -> + projectionBaseLocked( + envelope = null, + revision = current.residenceRevision, + ) + is ReaderRecord.Failure -> null + }, + ) + } + } else { + null + } + } + val pending = barrier ?: return resolved + pending.await() + } + } + + /** Installs the fence that prevents reactive delivery from observing a delete mid-tail. */ + private suspend fun beginDestructiveMutation(): CompletableDeferred = + stateLock.withLock { + check(destructiveMutationBarrier == null) { + "A destructive source-of-truth mutation is already active." + } + CompletableDeferred().also { destructiveMutationBarrier = it } + } + + /** Releases a destructive fence on every terminal path without stranding waiting readers. */ + private suspend fun finishDestructiveMutation(barrier: CompletableDeferred) { + try { + stateLock.withLock { + if (destructiveMutationBarrier === barrier) { + destructiveMutationBarrier = null + } + } + } finally { + barrier.complete(Unit) + } + } + + /** Plans one read against a coherent snapshot. */ + private fun planFor( + freshness: Freshness, + snapshot: KeyState, + envelope: ValueEnvelope?, + nowEpochMillis: Long, + status: KeyStatus?, + ): FetchPlan = + validator.plan( + FreshnessContext( + hasResidentValue = envelope != null, + meta = envelope?.meta, + epochStale = envelope != null && envelope.staleEpochAtCommit < snapshot.staleEpoch, + freshness = freshness, + nowEpochMillis = nowEpochMillis, + status = status, + ), + ) + + private fun planFor( + freshness: Freshness, + snapshot: ResidenceSnapshot, + envelope: ValueEnvelope? = snapshot.envelope, + ): FetchPlan = + planFor( + freshness = freshness, + snapshot = snapshot.state, + envelope = envelope, + nowEpochMillis = snapshot.nowEpochMillis, + status = snapshot.status, + ) + + private fun staleServingTolerated(freshness: Freshness): Boolean = + freshness == Freshness.CachedOrFetch || freshness == Freshness.StaleIfError + + /** Recognizes both fetched envelopes and the exact envelope installed by a writer boundary. */ + private fun isEngineConfirmedEnvelope(envelope: ValueEnvelope): Boolean = + envelope.origin == Origin.FETCHER || rawCommitResolution?.envelope === envelope + + /** Keeps synthetic-writer SOT provenance instead of re-stamping that exact envelope MEMORY. */ + private fun canRestampEngineMemoryOrigin( + memoryEnvelope: ValueEnvelope?, + memoryRevision: Long, + currentEnvelope: ValueEnvelope?, + currentRevision: Long, + ): Boolean = + canRestampMemoryOrigin( + memoryEnvelope = memoryEnvelope, + memoryRevision = memoryRevision, + currentEnvelope = currentEnvelope, + currentRevision = currentRevision, + ) && + !( + memoryEnvelope?.origin == Origin.SOT && + rawCommitResolution?.envelope === memoryEnvelope + ) + + private fun revalidatedSatisfiesDemand( + freshness: Freshness, + snapshot: ResidenceSnapshot, + plan: FetchPlan, + ): Boolean = + when (freshness) { + Freshness.MustBeFresh -> + snapshot.envelope?.let { envelope -> + isEngineConfirmedEnvelope(envelope) && + envelope.meta != null && + envelope.staleEpochAtCommit >= snapshot.state.staleEpoch && + snapshot.status?.durablyStale != true + } == true + + Freshness.CachedOrFetch, + Freshness.StaleIfError, + Freshness.LocalOnly, + is Freshness.MaxAge, + -> plan is FetchPlan.Skip + } + + /** Reserves joined/owned work and returns the exact residence/plan used under stateLock. */ + private suspend fun reserveFetch( + freshness: Freshness, + collectorEligibleResidence: ValueEnvelope? = null, + collectorEligibleRevision: Long? = null, + enforceCollectorEligibility: Boolean = false, + ): FetchReservation? { + while (true) { + val statusResidenceRevision = stateLock.withLock { residenceRevision } + val status = bookkeeper.status(key) + var retryStaleStatus = false + var pendingRevalidationOwner: FetchTicket? = null + val planned = + stateLock.withLock { + ensureOpen() + val now = wallClock.nowEpochMillis() + val snapshot = mutableState.value + val currentResidence = residence.value + if (residenceRevision != statusResidenceRevision) { + retryStaleStatus = true + return@withLock null + } + val directOwner = currentResidence?.directRevalidationOwner + val directDisposition = + directOwner?.disposition?.value as? FetchDisposition.Revalidated + if ( + directOwner != null && + directDisposition?.envelope === currentResidence && + !directOwner.outcome.isCompleted + ) { + pendingRevalidationOwner = directOwner + return@withLock null + } + val planningResidence = + if ( + enforceCollectorEligibility && + currentResidence?.directRevalidationOwner != null && + currentResidence !== collectorEligibleResidence + ) { + collectorEligibleResidence + } else { + currentResidence + } + val planningRevision = + if (planningResidence === currentResidence) { + residenceRevision + } else { + checkNotNull(collectorEligibleRevision) { + "A collector-owned historical residence requires its exact revision." + } + } + val plan = planFor(freshness, snapshot, planningResidence, now, status) + if (plan is FetchPlan.Skip) { + return@withLock null + } + val ticket = + FetchTicket( + outcome = CompletableDeferred(engineJob), + requestRevision = residenceRevision, + residenceRevisionAtLaunch = + currentResidence?.let { residenceRevision }, + residenceEnvelopeAtLaunch = currentResidence, + staleEpochAtLaunch = snapshot.staleEpoch, + nowEpochMillisAtLaunch = now, + statusAtLaunch = status, + ) + val result = transition(snapshot, KeyEvent.EnsureFetch(ticket)) + mutableState.value = result.state + PlannedFetchEffect( + effect = result.effect, + collectorEligibleResidence = planningResidence, + collectorEligibleRevision = planningRevision, + plan = plan, + ) + } + + if (retryStaleStatus) continue + + val owner = pendingRevalidationOwner + if (owner != null) { + owner.outcome.await() + continue + } + + val reservation = planned ?: return null + val ticket = + when (val effect = reservation.effect) { + is KeyEffect.Launch -> + effect.ticket.also { ticket -> + launchFetch( + ticket, + (reservation.plan as? FetchPlan.Conditional)?.etag, + ) + } + is KeyEffect.Join -> effect.ticket + else -> error("Ensure-fetch transition produced an invalid effect: $effect") + } + return FetchReservation( + ticket = ticket, + collectorEligibleResidence = reservation.collectorEligibleResidence, + collectorEligibleRevision = reservation.collectorEligibleRevision, + plan = reservation.plan, + ) + } + } + + /** Returns only the joined/owned identity for non-collector call sites. */ + private suspend fun ensureFetch(freshness: Freshness): FetchTicket? = + reserveFetch(freshness)?.ticket + + /** Runs the owned fetch independently of any individual waiter. */ + private fun launchFetch( + ticket: FetchTicket, + etag: String?, + ) { + val fetchJob = + engineScope.launch(start = CoroutineStart.UNDISPATCHED) { + var fetchRefHeld = false + try { + val mark = if (telemetry == null) null else TimeSource.Monotonic.markNow() + telemetry?.onFetchStarted(key) + val outcome = + try { + residencyHooks.retainFetchRef() + fetchRefHeld = true + currentCoroutineContext().ensureActive() + yield() + val result = fetcher.fetch(key, etag) + currentCoroutineContext().ensureActive() + when (result) { + is FetcherResult.Success -> + commitFetch(ticket, result.value, result.etag) + + is FetcherResult.NotModified -> + commitNotModified(ticket, result.etag) + + is FetcherResult.Error -> { + if (result.cause is CancellationException) throw result.cause + FetchOutcome.Failed( + exception = fetchResultException(result.cause), + atEpochMillis = wallClock.nowEpochMillis(), + ) + } + + FetcherResult.Deleted -> commitDeleted(ticket) + } + } catch (cancellation: CancellationException) { + ticket.outcome.cancel(cancellation) + settleFetch(ticket) + throw cancellation + } catch (failure: Throwable) { + FetchOutcome.Failed( + exception = fetchException(failure), + atEpochMillis = wallClock.nowEpochMillis(), + ) + } + + finishFetch(ticket, outcome, mark) + } finally { + if (fetchRefHeld) residencyHooks.releaseFetchRef() + } + } + + fetchJob.invokeOnCompletion { failure -> + if (failure != null) ticket.outcome.cancel(storeClosedCancellation()) + } + } + + /** Persists a value, closes raw source order at normal return, then converges the writer. */ + private suspend fun commitFetch( + ticket: FetchTicket, + value: V, + etag: String?, + ): FetchOutcome = + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + val meta = EngineStoreMeta(wallClock.nowEpochMillis(), etag) + var attribution: AttributionTag? = null + val effect = + stateLock.withLock { + val result = + transition( + mutableState.value, + KeyEvent.CommitFetch( + ticket = ticket, + value = value, + meta = meta, + ), + ) + attribution = result.state.attribution + if (result.effect == KeyEffect.Commit) { + val committedAttribution = checkNotNull(attribution) + mutableState.value = result.state + ticket.disposition.value = + FetchDisposition.Committing( + attribution = committedAttribution, + successfulWriteSequenceAtStart = + writeObservationBoundary.value.successfulSequence, + ) + updateWriteObservationBoundary { current -> + current.copy( + readerGen = result.state.readerGen, + observedAttribution = committedAttribution, + activeAttribution = committedAttribution, + activeRawPhase = ActiveRawPhase.Unobserved, + ) + } + } else { + mutableState.value = result.state + } + result.effect + } + + when (effect) { + KeyEffect.Superseded -> return@withLock FetchOutcome.Superseded + KeyEffect.Commit -> Unit + else -> error("Commit-fetch transition produced an invalid effect: $effect") + } + + val stamped = checkNotNull(attribution) + try { + sot.write(key, value) + } catch (cancellation: CancellationException) { + withContext(NonCancellable) { + stateLock.withLock { + terminalizeFailedWriteLocked( + stamped = stamped, + ticket = ticket, + disposition = FetchDisposition.Cancelled, + ) + } + } + throw cancellation + } catch (failure: Throwable) { + val exception = writeException(failure) + val atEpochMillis = wallClock.nowEpochMillis() + withContext(NonCancellable) { + stateLock.withLock { + terminalizeFailedWriteLocked( + stamped = stamped, + ticket = ticket, + disposition = FetchDisposition.Failed, + ) + } + } + bookkeeper.recordFailure(key, atEpochMillis) + return@withLock FetchOutcome.Failed( + exception = exception, + atEpochMillis = atEpochMillis, + bookkeepingRecorded = true, + ) + } + + // This CAS is the first instruction after normal write return. It separates every + // mutation-era observation from later source authority without waiting for stateLock. + val closedWriteBoundary = closeSuccessfulWriteBoundary() + val committedWriteSequence = withContext(NonCancellable) { + val sequence = + stateLock.withLock { + val committed = + convergeSuccessfulWriteLocked( + stamped = stamped, + value = value, + closed = closedWriteBoundary, + ) + ticket.disposition.value = + FetchDisposition.Committed( + successfulWriteSequence = committed.successfulWriteSequence, + attribution = stamped, + rawReaderGen = committed.readerGen, + rawCommitCutoff = committed.rawCommitCutoff, + authoritativeRawSequence = + committed.authoritativeRawSequence, + ) + committed.successfulWriteSequence + } + bookkeeper.recordSuccess(key, meta) + sequence + } + val disposition = + ticket.disposition.value as? FetchDisposition.Committed + ?: error("A successful write did not publish Committed disposition.") + FetchOutcome.Committed( + value = value, + successfulWriteSequence = committedWriteSequence, + attribution = stamped, + rawReaderGen = disposition.rawReaderGen, + rawCommitCutoff = disposition.rawCommitCutoff, + authoritativeRawSequence = disposition.authoritativeRawSequence, + ) + } + } + + /** + * Commits an acknowledged source-of-truth value without fetching or recording success. + * + * A synthetic ticket participates only in the attribution/disposition handshake; it never + * enters the fetch slot and never launches work. + */ + internal suspend fun applyWrite(value: V) { + ensureOpen() + val meta = EngineStoreMeta(wallClock.nowEpochMillis(), etag = null) + val ticket = FetchTicket(CompletableDeferred()) + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + val stamped = + stateLock.withLock { + val result = + transition( + mutableState.value, + KeyEvent.ApplyWrite( + ticket = ticket, + value = value, + meta = meta, + ), + ) + check(result.effect == KeyEffect.CommitWrite) { + "Apply-write transition produced an invalid effect: ${result.effect}" + } + mutableState.value = result.state + val attribution = checkNotNull(result.state.attribution) + ticket.disposition.value = + FetchDisposition.Committing( + attribution = attribution, + successfulWriteSequenceAtStart = + writeObservationBoundary.value.successfulSequence, + ) + updateWriteObservationBoundary { current -> + current.copy( + readerGen = result.state.readerGen, + observedAttribution = attribution, + activeAttribution = attribution, + activeRawPhase = ActiveRawPhase.Unobserved, + ) + } + attribution + } + + try { + sot.write(key, value) + } catch (cancellation: CancellationException) { + withContext(NonCancellable) { + stateLock.withLock { + terminalizeFailedWriteLocked( + stamped = stamped, + ticket = ticket, + disposition = FetchDisposition.Cancelled, + ) + } + } + throw cancellation + } catch (failure: Throwable) { + withContext(NonCancellable) { + stateLock.withLock { + terminalizeFailedWriteLocked( + stamped = stamped, + ticket = ticket, + disposition = FetchDisposition.Failed, + ) + } + } + throw writeHandleException(failure) + } + + // This CAS must remain the first non-suspending instruction after normal return. + val closedWriteBoundary = closeSuccessfulWriteBoundary() + withContext(NonCancellable) { + stateLock.withLock { + val committed = + convergeSuccessfulWriteLocked( + stamped = stamped, + value = value, + closed = closedWriteBoundary, + ) + ticket.disposition.value = + FetchDisposition.Committed( + successfulWriteSequence = committed.successfulWriteSequence, + attribution = stamped, + rawReaderGen = committed.readerGen, + rawCommitCutoff = committed.rawCommitCutoff, + authoritativeRawSequence = + committed.authoritativeRawSequence, + ) + } + } + } + } + events.tryEmit(KeyEvents.Written(key, Origin.SOT)) + } + + /** Refreshes resident metadata and durable success without a fetch. */ + internal suspend fun confirmFresh(etag: String?) { + ensureOpen() + val meta = EngineStoreMeta(wallClock.nowEpochMillis(), etag) + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + var replaced = false + stateLock.withLock state@{ + val current = residence.value ?: return@state + replaceResidenceLocked( + ValueEnvelope( + value = current.value, + origin = current.origin, + meta = meta, + staleEpochAtCommit = mutableState.value.staleEpoch, + ), + preserveProjectionAuthorizationLineage = true, + ) + syncObservedAttributionLocked(mutableState.value) + replaced = true + } + if (replaced) { + withContext(NonCancellable) { bookkeeper.recordSuccess(key, meta) } + } + } + } + } + + /** Closes raw source order and converges its durable winner before bookkeeping. */ + private fun convergeSuccessfulWriteLocked( + stamped: AttributionTag, + value: V, + closed: ClosedWriteBoundary, + ): DurableWriteResolution { + val matchingObservation = closed.phase.matchingObservationOrNull() + val authoritativeRawSequence = + matchingObservation?.rawSequence + + // RYW makes the successful writer the winner over every pre-close intermediate. Only the + // exact captured matching token may later reuse this installed FETCHER envelope. + installWriterEnvelopeLocked(value, stamped) + val consumed = + transition( + mutableState.value, + KeyEvent.ConsumeAttribution(matchingObservation?.attributionAtObservation), + ) + mutableState.value = consumed.state + val consumedAttribution = (consumed.effect as KeyEffect.Consumed).tag + val revoked = transition(consumed.state, KeyEvent.RevokeAttribution) + mutableState.value = revoked.state + + syncObservedAttributionLocked(mutableState.value) + updateWriteObservationBoundary { current -> + if (current.activeAttribution === stamped) { + current.copy( + activeAttribution = null, + activeRawPhase = ActiveRawPhase.Unobserved, + ) + } else { + current + } + } + rawCommitResolution = + RawCommitResolution( + readerGen = closed.readerGen, + rawCommitCutoff = closed.rawCommitCutoff, + authoritativeRawSequence = authoritativeRawSequence, + residenceRevision = residenceRevision, + envelope = residence.value, + consumedAttribution = consumedAttribution, + ) + return DurableWriteResolution( + successfulWriteSequence = closed.successfulWriteSequence, + readerGen = closed.readerGen, + rawCommitCutoff = closed.rawCommitCutoff, + authoritativeRawSequence = authoritativeRawSequence, + ) + } + + private fun installWriterEnvelopeLocked( + value: V, + stamped: AttributionTag, + ) { + replaceResidenceLocked( + ValueEnvelope( + value = value, + origin = stamped.origin, + meta = stamped.meta, + staleEpochAtCommit = stamped.staleEpochAtCommit, + ), + ) + } + + /** Atomically closes the raw phase and fences queued pre-return notifications. */ + private fun closeSuccessfulWriteBoundary(): ClosedWriteBoundary { + while (true) { + val current = writeObservationBoundary.value + val nextSequence = current.successfulSequence + 1L + val pendingWriteAttribution = + if ( + current.readerSessionActive && + current.activeRawPhase !is ActiveRawPhase.Matching + ) { + current.activeAttribution ?: current.pendingWriteAttribution + } else { + current.pendingWriteAttribution + } + val updated = + current.copy( + successfulSequence = nextSequence, + activeRawPhase = ActiveRawPhase.Unobserved, + pendingWriteAttribution = pendingWriteAttribution, + ) + if (writeObservationBoundary.compareAndSet(current, updated)) { + return ClosedWriteBoundary( + readerGen = current.readerGen, + rawCommitCutoff = current.latestRawSequence, + phase = current.activeRawPhase, + successfulWriteSequence = nextSequence, + ) + } + } + } + + /** Atomically revokes failed-write provenance and wakes every captured provisional row. */ + private fun terminalizeFailedWriteLocked( + stamped: AttributionTag, + ticket: FetchTicket, + disposition: FetchDisposition, + ) { + require( + disposition === FetchDisposition.Failed || + disposition === FetchDisposition.Cancelled, + ) + val revoked = transition(mutableState.value, KeyEvent.RevokeAttribution) + mutableState.value = revoked.state + syncObservedAttributionLocked(revoked.state) + updateWriteObservationBoundary { current -> + if (current.activeAttribution === stamped) { + current.copy( + activeAttribution = null, + activeRawPhase = ActiveRawPhase.Unobserved, + ) + } else { + current + } + } + ticket.disposition.value = disposition + } + + /** Applies NotModified only when its launch baseline is still the live residence revision. */ + private suspend fun commitNotModified( + ticket: FetchTicket, + etag: String?, + ): FetchOutcome = + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + val now = wallClock.nowEpochMillis() + val baseline = ticket.residenceRevisionAtLaunch + var refreshedMeta: StoreMeta? = null + val outcome = + stateLock.withLock { + val result = + transition(mutableState.value, KeyEvent.CommitRevalidated(ticket)) + val classified = + when (result.effect) { + KeyEffect.CommitRevalidation -> { + val current = residence.value + if (baseline == null && current == null) { + FetchOutcome.Failed( + exception = notModifiedWithoutValueException(), + atEpochMillis = now, + ) + } else if ( + baseline == null || + current == null || + residenceRevision != baseline + ) { + // A null launch baseline with residence present at commit + // is an obsolete launch snapshot (residence hydrated + // mid-flight), not an adapter-contract violation; only a + // 304 with no value on either side is Failed. + FetchOutcome.ObsoleteRevalidation + } else { + val age = elapsedAge(now, current.meta) + val meta = EngineStoreMeta(now, etag ?: current.meta?.etag) + refreshedMeta = meta + val refreshed = + current.copy( + origin = Origin.FETCHER, + meta = meta, + staleEpochAtCommit = result.state.staleEpoch, + directRevalidationOwner = ticket, + ) + val revision = replaceResidenceLocked(refreshed) + FetchOutcome.Revalidated(revision, refreshed, age) + } + } + + KeyEffect.Superseded -> FetchOutcome.Superseded + else -> error( + "Commit-revalidated transition produced an invalid effect: " + + result.effect, + ) + } + markDisposition(ticket, classified) + mutableState.value = result.state + syncObservedAttributionLocked(result.state) + classified + } + refreshedMeta?.let { meta -> + withContext(NonCancellable) { bookkeeper.recordSuccess(key, meta) } + } + outcome + } + } + + /** Applies a server deletion only after its ticket is still proven current. */ + private suspend fun commitDeleted(ticket: FetchTicket): FetchOutcome { + val outcome = commitDeletedUnderFence(ticket) + if (outcome is FetchOutcome.Deleted) { + telemetry?.onCleared(key) + events.tryEmit(KeyEvents.Deleted(key)) + } + return outcome + } + + /** Runs the destructive server-deletion transaction without invoking extension code. */ + private suspend fun commitDeletedUnderFence(ticket: FetchTicket): FetchOutcome = + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + val superseded = + stateLock.withLock { + val slot = mutableState.value.fetch as? FetchSlot.InFlight + slot == null || + slot.ticket !== ticket || + slot.clearEpochAtLaunch != mutableState.value.clearEpoch + } + if (superseded) return@withLock FetchOutcome.Superseded + + withContext(NonCancellable) { + val barrier = beginDestructiveMutation() + try { + val deleteFailure = + try { + sot.delete(key) + null + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + FetchOutcome.Failed( + exception = serverDeletePersistenceException(failure), + atEpochMillis = wallClock.nowEpochMillis(), + ) + } + if (deleteFailure != null) return@withContext deleteFailure + + val absenceRevision = stateLock.withLock { + val result = + transition(mutableState.value, KeyEvent.CommitDeleted(ticket)) + check(result.effect == KeyEffect.CommitDelete) { + "Commit-deleted transition produced an invalid effect: ${result.effect}" + } + ticket.disposition.value = FetchDisposition.Deleted + mutableState.value = result.state + syncObservedAttributionLocked(result.state) + replaceResidenceLocked(null) + } + try { + bookkeeper.forget(key) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + return@withContext FetchOutcome.Failed( + exception = + maintenancePersistenceException( + operation = "server deletion cleanup", + failure = failure, + ), + atEpochMillis = wallClock.nowEpochMillis(), + ) + } + FetchOutcome.Deleted(absenceRevision) + } finally { + finishDestructiveMutation(barrier) + } + } + } + } + + private suspend fun settleFetch(ticket: FetchTicket) { + withContext(NonCancellable) { + stateLock.withLock { + val result = transition(mutableState.value, KeyEvent.SettleFetch(ticket)) + mutableState.value = result.state + } + } + } + + private suspend fun finishFetch( + ticket: FetchTicket, + outcome: FetchOutcome, + mark: TimeMark?, + ) { + if (outcome is FetchOutcome.Failed && !outcome.bookkeepingRecorded) { + // Failure bookkeeping remains cancellable so engine cancellation releases the ordered + // write/fence admission. Only the post-release publication tail is non-cancellable. + val classified = finishFailedFetch(ticket, outcome) + withContext(NonCancellable) { + notifyFetchTerminal(classified, mark) + notifyFetchEvent(classified) + completeTicket(ticket, classified) + } + } else { + withContext(NonCancellable) { + val classified = + if (outcome is FetchOutcome.Failed) { + outcome + } else { + stateLock.withLock { classifySettledOutcome(ticket, outcome) } + } + notifyFetchTerminal(classified, mark) + notifyFetchEvent(classified) + completeTicket(ticket, classified) + } + } + } + + /** Runs the failed-fetch persistence tail and returns only after every engine lock is released. */ + private suspend fun finishFailedFetch( + ticket: FetchTicket, + outcome: FetchOutcome.Failed, + ): FetchOutcome = + try { + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + val classified = + stateLock.withLock { + classifySettledOutcome(ticket, outcome) + } + if (classified is FetchOutcome.Failed) { + bookkeeper.recordFailure(key, classified.atEpochMillis) + } + classified + } + } + } catch (cancellation: CancellationException) { + ticket.outcome.cancel(cancellation) + settleFetch(ticket) + throw cancellation + } + + /** Fires terminal telemetry after settlement and before any waiter observes ticket completion. */ + private fun notifyFetchTerminal( + outcome: FetchOutcome, + mark: TimeMark?, + ) { + val sink = telemetry ?: return + when (outcome) { + is FetchOutcome.Committed, + is FetchOutcome.Revalidated, + -> sink.onFetchSucceeded(key, checkNotNull(mark).elapsedNow()) + + is FetchOutcome.Failed -> + sink.onFetchFailed(key, outcome.exception.error, checkNotNull(mark).elapsedNow()) + + is FetchOutcome.Deleted, + FetchOutcome.ObsoleteRevalidation, + FetchOutcome.Superseded, + -> Unit + } + } + + /** Publishes successful fetch writes after classification and before ticket completion. */ + private fun notifyFetchEvent(outcome: FetchOutcome) { + if (outcome is FetchOutcome.Committed) { + events.tryEmit(KeyEvents.Written(key, Origin.FETCHER)) + } + } + + private fun classifySettledOutcome( + ticket: FetchTicket, + outcome: FetchOutcome, + ): FetchOutcome { + val result = transition(mutableState.value, KeyEvent.SettleFetch(ticket)) + val classified = when (result.effect) { + KeyEffect.Superseded -> FetchOutcome.Superseded + KeyEffect.Settled, + KeyEffect.Ignored, + -> outcome + + else -> error("Settle-fetch transition produced an invalid effect: ${result.effect}") + } + markDisposition(ticket, classified) + mutableState.value = result.state + return classified + } + + private fun completeTicket( + ticket: FetchTicket, + outcome: FetchOutcome, + ) { + if (engineJob.isActive) { + ticket.outcome.complete(outcome) + } else { + ticket.outcome.cancel(storeClosedCancellation()) + } + } + + private fun markDisposition( + ticket: FetchTicket, + outcome: FetchOutcome, + ) { + ticket.disposition.value = + when (outcome) { + is FetchOutcome.Committed -> + FetchDisposition.Committed( + successfulWriteSequence = outcome.successfulWriteSequence, + attribution = outcome.attribution, + rawReaderGen = outcome.rawReaderGen, + rawCommitCutoff = outcome.rawCommitCutoff, + authoritativeRawSequence = outcome.authoritativeRawSequence, + ) + is FetchOutcome.Revalidated -> FetchDisposition.Revalidated(outcome.envelope) + is FetchOutcome.Deleted -> FetchDisposition.Deleted + is FetchOutcome.Failed -> FetchDisposition.Failed + FetchOutcome.ObsoleteRevalidation -> FetchDisposition.ObsoleteRevalidation + FetchOutcome.Superseded -> FetchDisposition.Superseded + } + } + + internal suspend fun invalidate() { + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + try { + bookkeeper.markStale(key) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + throw maintenancePersistenceException("invalidate", failure) + } + applyEvent(KeyEvent.Invalidate) + } + } + telemetry?.onInvalidated(key) + events.tryEmit(KeyEvents.Invalidated(key)) + } + + /** Signals resident demand only while the previously advanced watermark still covers it. */ + internal suspend fun invalidateResident() { + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + if (bookkeeper.status(key)?.durablyStale == true) { + applyEvent(KeyEvent.Invalidate) + } + } + } + telemetry?.onInvalidated(key) + events.tryEmit(KeyEvents.Invalidated(key)) + } + + /** Deletes persistence first, then performs the irreversible state/bookkeeping tail. */ + internal suspend fun clear() { + maintenanceCoordinator.withCommit(keyId.namespace) { + writeLock.withLock { + withContext(NonCancellable) { + val barrier = beginDestructiveMutation() + try { + try { + sot.delete(key) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + throw clearPersistenceException(failure) + } + + stateLock.withLock { applyClearTransitionLocked() } + try { + bookkeeper.forget(key) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + throw maintenancePersistenceException("clear", failure) + } + } finally { + finishDestructiveMutation(barrier) + } + } + } + } + telemetry?.onCleared(key) + events.tryEmit(KeyEvents.Deleted(key)) + } + + /** Applies only the resident clear atom; store-scoped maintenance owns the durable fence. */ + internal suspend fun clearResident() { + writeLock.withLock { + withContext(NonCancellable) { + val barrier = beginDestructiveMutation() + try { + stateLock.withLock { applyClearTransitionLocked() } + } finally { + finishDestructiveMutation(barrier) + } + } + } + } + + /** Reports one completed store-scoped clear after its maintenance fence has been released. */ + internal fun notifyBulkClearCompleted() { + telemetry?.onCleared(key) + events.tryEmit(KeyEvents.Deleted(key)) + } + + /** Applies the clear transition while [stateLock] is held. */ + private fun applyClearTransitionLocked() { + val result = transition(mutableState.value, KeyEvent.Clear) + mutableState.value = result.state + syncObservedAttributionLocked(result.state) + check(result.effect == KeyEffect.ClearResidence) { + "Clear transition produced an invalid effect: ${result.effect}" + } + replaceResidenceLocked(null) + } + + /** Direct one-shot hydration used by get and memory-miss stream startup. */ + private suspend fun hydrateFromSot(): ResidenceSnapshot = + writeLock.withLock { + val status = bookkeeper.status(key) + val capturedRevision = stateLock.withLock { residenceRevision } + val row = + try { + sot.reader(key).first() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + throw readerException(failure) + } + + stateLock.withLock { + val current = residence.value + val resolved = + when { + current != null -> current + residenceRevision != capturedRevision -> current + row == null -> residence.value + + else -> { + val currentEpoch = mutableState.value.staleEpoch + val staleEpochAtCommit = + if (status?.durablyStale == true) currentEpoch - 1L else currentEpoch + val hydratedMeta = + status?.meta?.let { meta -> + EngineStoreMeta( + writtenAtEpochMillis = meta.writtenAtEpochMillis, + etag = null, + ) + } + ValueEnvelope( + value = row, + origin = Origin.SOT, + meta = hydratedMeta, + staleEpochAtCommit = staleEpochAtCommit, + ).also(::replaceResidenceLocked) + } + } + ResidenceSnapshot( + state = mutableState.value, + envelope = resolved, + revision = residenceRevision, + status = status, + nowEpochMillis = wallClock.nowEpochMillis(), + projectionBase = projectionBaseLocked(resolved, residenceRevision), + ) + } + } + + /** Coherent state/residence snapshot used at public delivery boundaries. */ + private suspend fun residenceSnapshot(): ResidenceSnapshot { + val status = bookkeeper.status(key) + return stateLock.withLock { + ResidenceSnapshot( + state = mutableState.value, + envelope = residence.value, + revision = residenceRevision, + status = status, + nowEpochMillis = wallClock.nowEpochMillis(), + projectionBase = projectionBaseLocked(residence.value, residenceRevision), + ) + } + } + + /** Distinguishes a newer semantic residence from a same-envelope reader replay. */ + @Suppress("UNCHECKED_CAST") + private fun residenceAdvancedFrom( + ticket: FetchTicket, + snapshot: ResidenceSnapshot, + ): Boolean { + val launchEnvelope = ticket.residenceEnvelopeAtLaunch as? ValueEnvelope + return snapshot.state.staleEpoch > ticket.staleEpochAtLaunch || + ( + snapshot.revision != ticket.requestRevision && + snapshot.envelope != launchEnvelope + ) + } + + /** Builds one live stream with a collector-local serialized delivery controller. */ + internal fun stream(freshness: Freshness): Flow> { + ensureOpen() + return channelFlow { + ensureOpen() + val producer = this + val closeHandle = + closeSignal.invokeOnCompletion { producer.cancel(storeClosedCancellation()) } + try { + val memory = residenceSnapshot() + var startupReaderFailure: StoreException? = null + var hydrated: ResidenceSnapshot? = null + if (memory.envelope == null) { + try { + hydrated = hydrateFromSot() + } catch (failure: StoreException) { + startupReaderFailure = failure + } + } + + var planning = hydrated ?: residenceSnapshot() + var planningEpoch = planning.state.staleEpoch + var planningEligibleEnvelope = + if ( + planning.envelope?.directRevalidationOwner != null && + planning.envelope !== memory.envelope + ) { + memory.envelope + } else { + planning.envelope + } + var planningEligibleRevision = + if (planningEligibleEnvelope === memory.envelope) { + memory.revision + } else { + planning.revision + } + var plan = + planFor( + freshness = freshness, + snapshot = planning, + envelope = planningEligibleEnvelope, + ) + val initialReservation = + if (plan is FetchPlan.Skip) { + null + } else { + reserveFetch( + freshness = freshness, + collectorEligibleResidence = planningEligibleEnvelope, + collectorEligibleRevision = planningEligibleRevision, + enforceCollectorEligibility = + planning.envelope?.directRevalidationOwner != null && + planning.envelope !== planningEligibleEnvelope, + ) + } + val reservedCollectorEnvelope = + initialReservation?.collectorEligibleResidence ?: planningEligibleEnvelope + val reservedCollectorRevision = + initialReservation?.collectorEligibleRevision ?: planningEligibleRevision + val reservedPlan = initialReservation?.plan ?: plan + var initialTicket = initialReservation?.ticket + planning = residenceSnapshot() + planningEligibleEnvelope = + if ( + planning.envelope?.directRevalidationOwner != null && + planning.envelope !== memory.envelope + ) { + memory.envelope + } else { + planning.envelope + } + planningEligibleRevision = + if (planningEligibleEnvelope === memory.envelope) { + memory.revision + } else { + planning.revision + } + plan = + planFor( + freshness = freshness, + snapshot = planning, + envelope = planningEligibleEnvelope, + ) + + val delivery = + StreamDelivery( + producer = producer, + freshness = freshness, + startupReaderFailure = startupReaderFailure, + ) + beforeInitialDeliveryTestGate() + val initialDelivery = delivery.deliverInitial( + memoryEnvelope = memory.envelope, + memoryRevision = memory.revision, + reservedCollectorEnvelope = reservedCollectorEnvelope, + reservedCollectorRevision = reservedCollectorRevision, + reservedPlan = reservedPlan, + ticket = initialTicket, + ) + planning = initialDelivery.snapshot + plan = initialDelivery.plan + initialTicket = initialDelivery.ticket + + if (freshness == Freshness.MustBeFresh && initialTicket != null) { + delivery.startProjectionObserver() + while (true) { + val ticket = initialTicket ?: break + val outcome = ticket.outcome.await() + beforeTicketOutcomeDeliveryTestGate() + when (outcome) { + is FetchOutcome.Committed -> { + delivery.retainCommittedTicket(ticket, outcome) + break + } + + is FetchOutcome.Revalidated -> { + delivery.clearInitialTicket(ticket) + when (val delivered = delivery.deliverRevalidated(outcome)) { + RevalidatedDelivery.Delivered -> break + RevalidatedDelivery.Obsolete -> { + initialTicket = ensureFetch(freshness) + if (initialTicket == null) break + } + + is RevalidatedDelivery.Replacement -> + initialTicket = delivered.ticket + } + } + + FetchOutcome.ObsoleteRevalidation -> { + delivery.clearInitialTicket(ticket) + initialTicket = ensureFetch(freshness) + if (initialTicket == null) break + } + + is FetchOutcome.Failed -> { + delivery.clearInitialTicket(ticket) + delivery.deliverTerminalOutcome(outcome) + close() + return@channelFlow + } + + is FetchOutcome.Deleted -> { + delivery.clearInitialTicket(ticket) + delivery.deliverTerminalOutcome(outcome) + close() + return@channelFlow + } + + FetchOutcome.Superseded -> { + delivery.clearInitialTicket(ticket) + delivery.deliverTerminalError(supersededException()) + close() + return@channelFlow + } + } + } + planning = residenceSnapshot() + planningEpoch = + maxOf( + planningEpoch, + planning.envelope?.staleEpochAtCommit ?: planningEpoch, + ) + plan = + planFor( + freshness = freshness, + snapshot = planning, + ) + initialTicket = null + } + + delivery.start( + planningEpoch = planningEpoch, + initialTicket = initialTicket, + initialPlan = plan, + ) + awaitCancellation() + } finally { + closeHandle.dispose() + } + }.conflateLatestData() + } + + /** Collector-local sequencer. Every public send occurs while [mutex] is held. */ + private inner class StreamDelivery( + private val producer: ProducerScope>, + private val freshness: Freshness, + private val startupReaderFailure: StoreException?, + ) { + private val mutex = Mutex() + private var publicHasValue = false + private var loadingVisible = false + private var localOnlyMissingEmitted = false + private var watchedTicket: FetchTicket? = null + private var awaitingCommitted: CommittedReaderWait? = null + private var handledCommittedTicket: FetchTicket? = null + private var latestReaderRecord: ReaderRecord? = null + private var publicServedStale = false + private var servedStaleForWatchedTicket = false + private var lastRevalidationRequestedRevision: Long? = null + private var terminalFailedDemand: FetchTicket? = null + private var suppressMissingUntilReaderRecovery = startupReaderFailure != null + private var serverDeletionObserved = false + private var lastDataFingerprint: DataFingerprint? = null + private var lastConfirmedRevision: Long? = null + private var projectionAuthorization: ProjectionAuthorization? = null + private var projectionObserverStarted = false + private val pendingFailureHandoffs = ArrayDeque() + private var ticketLaunchBaseline: TicketLaunchBaselineEntry? = null + + /** Propagates cooperative fetch cancellation to the owning public stream. */ + private suspend fun awaitTicketOutcome(ticket: FetchTicket): FetchOutcome = + try { + ticket.outcome.await() + } catch (cancellation: CancellationException) { + producer.cancel(cancellation) + throw cancellation + } + + /** Plans only from residence this collector is authorized to observe. */ + private fun collectorPlanFor( + snapshot: ResidenceSnapshot, + eligibleBaseline: ValueEnvelope? = lastDataFingerprint?.envelope, + eligibleBaselineRevision: Long? = + projectionAuthorization?.base?.revision ?: lastConfirmedRevision, + ): CollectorFetchPlan { + val current = snapshot.envelope + val currentIsForeignOwner = + current?.directRevalidationOwner != null && current !== eligibleBaseline + val eligibleEnvelope = if (currentIsForeignOwner) eligibleBaseline else current + val eligibleRevision = + if (currentIsForeignOwner) { + eligibleBaselineRevision ?: snapshot.revision + } else { + snapshot.revision + } + val eligibleProjectionBase = + if (currentIsForeignOwner) { + projectionAuthorization?.base?.takeIf { base -> + base.envelope === eligibleEnvelope && base.revision == eligibleRevision + } + } else { + snapshot.projectionBase + } + return CollectorFetchPlan( + eligibleEnvelope = eligibleEnvelope, + eligibleRevision = eligibleRevision, + plan = + planFor( + freshness = freshness, + snapshot = snapshot, + envelope = eligibleEnvelope, + ), + currentIsForeignOwner = currentIsForeignOwner, + eligibleProjectionBase = eligibleProjectionBase, + ) + } + + private fun collectorPlanFor( + snapshot: ResidenceSnapshot, + eligibleBaseline: EligibleBaseline, + ): CollectorFetchPlan = + collectorPlanFor( + snapshot = snapshot, + eligibleBaseline = eligibleBaseline.envelope, + eligibleBaselineRevision = eligibleBaseline.revision, + ) + + /** Rechecks collector demand under stateLock without changing the ticket's live baseline. */ + private suspend fun ensureFetchForCollector( + collectorPlan: CollectorFetchPlan, + ): FetchTicket? { + val reservation = reserveFetch( + freshness = freshness, + collectorEligibleResidence = collectorPlan.eligibleEnvelope, + collectorEligibleRevision = collectorPlan.eligibleRevision, + enforceCollectorEligibility = collectorPlan.currentIsForeignOwner, + ) ?: return null + rememberTicketLaunchBaseline( + reservation.ticket, + TicketLaunchBaseline( + reservation.collectorEligibleResidence, + reservation.collectorEligibleRevision, + reservation.plan, + ), + ) + return reservation.ticket + } + + private fun rememberTicketLaunchBaseline( + ticket: FetchTicket, + baseline: TicketLaunchBaseline, + ) { + ticketLaunchBaseline = TicketLaunchBaselineEntry(ticket, baseline) + } + + /** Keeps a mapped SoT value eligible when a later 304 owns only its refreshed metadata. */ + private fun readerEligibleBaseline( + notification: ReaderRecord, + current: ValueEnvelope?, + ): EligibleBaseline { + val visible = lastDataFingerprint?.envelope + val row = notification as? ReaderRecord.Row + if (current != null && current === visible) { + return EligibleBaseline(current, row?.residenceRevision ?: checkNotNull(lastConfirmedRevision)) + } + val mapped = row?.envelope + val sameValue = + mapped != null && + if (overlay == null) { + mapped.value == current?.value + } else { + mapped.value === current?.value + } + return if ( + current?.directRevalidationOwner != null && + mapped?.directRevalidationOwner == null && + sameValue + ) { + EligibleBaseline(mapped, row.residenceRevision) + } else { + EligibleBaseline( + envelope = visible, + revision = projectionAuthorization?.base?.revision ?: lastConfirmedRevision ?: 0L, + ) + } + } + + /** Reconstructs the policy posture that was eligible when [ticket] reserved demand. */ + private fun launchBaselineFor( + ticket: FetchTicket, + snapshot: ResidenceSnapshot, + ): TicketLaunchBaseline { + val remembered = ticketLaunchBaseline?.takeIf { it.ticket === ticket }?.baseline + @Suppress("UNCHECKED_CAST") + val launchBaseline = + remembered ?: run { + val envelope = ticket.residenceEnvelopeAtLaunch as? ValueEnvelope + TicketLaunchBaseline( + envelope = envelope, + revision = ticket.requestRevision, + plan = + validator.plan( + FreshnessContext( + hasResidentValue = envelope != null, + meta = envelope?.meta, + epochStale = + envelope != null && + envelope.staleEpochAtCommit < + ticket.staleEpochAtLaunch, + freshness = freshness, + nowEpochMillis = ticket.nowEpochMillisAtLaunch, + status = ticket.statusAtLaunch, + ), + ), + ) + } + val currentPlan = + planFor( + freshness = freshness, + snapshot = snapshot, + envelope = launchBaseline.envelope, + ) + return TicketLaunchBaseline( + envelope = launchBaseline.envelope, + revision = launchBaseline.revision, + plan = + if (launchBaseline.plan.servesResident) { + currentPlan + } else { + launchBaseline.plan + }, + ) + } + + suspend fun deliverInitial( + memoryEnvelope: ValueEnvelope?, + memoryRevision: Long, + reservedCollectorEnvelope: ValueEnvelope?, + reservedCollectorRevision: Long, + reservedPlan: FetchPlan, + ticket: FetchTicket?, + ): InitialDelivery = + mutex.withLock { + if (ticket != null) { + rememberTicketLaunchBaseline( + ticket, + TicketLaunchBaseline( + reservedCollectorEnvelope, + reservedCollectorRevision, + reservedPlan, + ), + ) + } + if ( + overlay != null && + reservedCollectorEnvelope == null && + reservedPlan !is FetchPlan.Skip && + startupReaderFailure == null + ) { + emitLoadingLocked() + } + var snapshot = residenceSnapshot() + var collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + var plan = collectorPlan.plan + var effectiveTicket = ticket + if (plan !is FetchPlan.Skip && effectiveTicket == null) { + effectiveTicket = ensureFetchForCollector(collectorPlan) + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + } + afterInitialPlanningSnapshotTestGate() + val pendingSettledTailAtRecapture = + effectiveTicket != null && + !effectiveTicket.outcome.isCompleted && + (snapshot.state.fetch as? FetchSlot.InFlight)?.ticket !== effectiveTicket && + effectiveTicket.requestRevision != snapshot.revision + val pendingExactRevalidation = + pendingSettledTailAtRecapture && + (effectiveTicket.disposition.value as? FetchDisposition.Revalidated) + ?.envelope === snapshot.envelope + if (pendingExactRevalidation) { + if ( + reservedCollectorEnvelope != null && + reservedPlan.servesResident && + plan.servesResident + ) { + deliverDataLocked( + reservedCollectorEnvelope, + revision = reservedCollectorRevision, + authority = DataDeliveryAuthority.CollectorBaseline, + ) + } else { + emitLoadingLocked() + } + } else if ( + pendingSettledTailAtRecapture && + (snapshot.envelope == null || !plan.servesResident) + ) { + emitLoadingLocked() + } + val observedOuterOutcome = + when { + effectiveTicket == null -> null + effectiveTicket.outcome.isCompleted -> awaitTicketOutcome(effectiveTicket) + pendingSettledTailAtRecapture -> awaitTicketOutcome(effectiveTicket) + else -> null + } + if (pendingSettledTailAtRecapture) { + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + } + val completedOuterOutcome = + observedOuterOutcome?.takeIf { outcome -> + freshness != Freshness.MustBeFresh && + effectiveTicket?.let { ticket -> + if (outcome is FetchOutcome.Failed) { + residenceAdvancedFrom(ticket, snapshot) + } else { + ticket.requestRevision != snapshot.revision + } + } == true + } + val completedExactRevalidation = + (completedOuterOutcome as? FetchOutcome.Revalidated) + ?.takeIf { snapshot.envelope === it.envelope } + var completedExactRevalidationDelivery: RevalidatedDelivery? = null + if ( + completedExactRevalidation != null && + !revalidatedSatisfiesDemand( + snapshot, + planFor( + freshness = freshness, + snapshot = snapshot, + ), + ) + ) { + val revalidationDelivery = + deliverRevalidatedLocked( + outcome = completedExactRevalidation, + watchReplacement = false, + ) + completedExactRevalidationDelivery = revalidationDelivery + effectiveTicket = + when (val delivery = revalidationDelivery) { + is RevalidatedDelivery.Replacement -> delivery.ticket + RevalidatedDelivery.Delivered -> null + RevalidatedDelivery.Obsolete -> { + snapshot = residenceSnapshot() + collectorPlan = + collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + if (plan is FetchPlan.Skip) { + null + } else { + ensureFetchForCollector(collectorPlan) + } + } + } + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + } + if (completedOuterOutcome is FetchOutcome.Committed) { + installCommittedWaitLocked( + checkNotNull(effectiveTicket), + completedOuterOutcome, + ) + } else if ( + completedOuterOutcome != null && + completedExactRevalidation == null && + (completedOuterOutcome !is FetchOutcome.Deleted || snapshot.envelope != null) + ) { + // The outer ticket no longer covers the final residence. Hand ownership to + // its replacement before the first public send, then surface the old outcome + // with its pre-handoff served-stale state. + val outerTicket = checkNotNull(effectiveTicket) + if (completedOuterOutcome is FetchOutcome.Deleted) { + handleOutcomeLocked(outerTicket, completedOuterOutcome, false) + serverDeletionObserved = false + effectiveTicket = null + } + val replacement = + if (plan is FetchPlan.Skip) null else ensureFetchForCollector(collectorPlan) + if (replacement != null) { + if (completedOuterOutcome is FetchOutcome.Failed) { + enqueueFailureHandoff( + SettledTicketHandoff( + ticket = outerTicket, + outcome = completedOuterOutcome, + servedStale = false, + ), + ) + } + effectiveTicket = replacement + } + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + } + var initialPublicDeliveryCompleted = + completedExactRevalidationDelivery == RevalidatedDelivery.Delivered || + (completedExactRevalidationDelivery is RevalidatedDelivery.Replacement && + completedExactRevalidationDelivery.publicDeliveryCompleted) + if ( + effectiveTicket != null && + (effectiveTicket !== ticket || observedOuterOutcome == null) + ) { + beforeReplacementDispositionClassificationTestGate() + } + val classifiedReplacementTickets = mutableSetOf() + var replacementClassifications = 0 + var replacementClassificationCapped = false + var replacementCommitRetainedServableRow = false + var replacementCommittingTailRetained = false + replacementClassification@ while (!initialPublicDeliveryCompleted) { + val candidate = effectiveTicket ?: break + if (candidate === ticket && observedOuterOutcome != null) break + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + val disposition = candidate.disposition.value + if (disposition is FetchDisposition.InFlight) break + val originalServableBaseline = + candidate === ticket && + reservedCollectorEnvelope != null && + reservedPlan.servesResident && + collectorPlan.plan.servesResident && + collectorPlan.eligibleEnvelope == reservedCollectorEnvelope + if (disposition is FetchDisposition.Committing) { + if (originalServableBaseline) { + deliverDataLocked( + envelope = checkNotNull(reservedCollectorEnvelope), + revision = reservedCollectorRevision, + originOverride = + if ( + canRestampEngineMemoryOrigin( + memoryEnvelope = memoryEnvelope, + memoryRevision = memoryRevision, + currentEnvelope = snapshot.envelope, + currentRevision = snapshot.revision, + ) + ) { + Origin.MEMORY + } else { + null + }, + authority = DataDeliveryAuthority.CollectorBaseline, + ) + } else if ( + collectorPlan.eligibleEnvelope == null || + !collectorPlan.plan.servesResident || + collectorPlan.eligibleEnvelope.value == disposition.attribution.value + ) { + emitLoadingLocked() + } + replacementCommittingTailRetained = true + break@replacementClassification + } + if (++replacementClassifications > 32) { + if ( + collectorPlan.eligibleEnvelope == null || + !collectorPlan.plan.servesResident + ) { + emitLoadingLocked() + } + replacementClassificationCapped = true + break@replacementClassification + } + if (!classifiedReplacementTickets.add(candidate)) { + break@replacementClassification + } + var revalidationReplacementReservedBeforeTail: FetchTicket? = null + when (disposition) { + is FetchDisposition.Committed -> { + if ( + candidate === ticket || + collectorPlan.eligibleEnvelope == null || + !collectorPlan.plan.servesResident || + collectorPlan.eligibleEnvelope.value == + disposition.attribution.value + ) { + emitLoadingLocked() + } else { + replacementCommitRetainedServableRow = true + } + } + + is FetchDisposition.Revalidated -> { + if (snapshot.envelope === disposition.envelope) { + val baseline = launchBaselineFor(candidate, snapshot) + val currentPlan = + planFor( + freshness = freshness, + snapshot = snapshot, + ) + val currentSatisfiesDemand = + revalidatedSatisfiesDemand(snapshot, currentPlan) + // Revalidated publishes before its bookkeeping/outcome tail. The + // old durable status cannot supersede an owner covering this epoch. + val exactOwnerCoversCurrentEpoch = + disposition.envelope.staleEpochAtCommit >= + snapshot.state.staleEpoch + val replacement = + if (currentSatisfiesDemand || exactOwnerCoversCurrentEpoch) { + null + } else { + ensureFetchForCollector( + CollectorFetchPlan( + eligibleEnvelope = baseline.envelope, + eligibleRevision = baseline.revision, + plan = baseline.plan, + currentIsForeignOwner = + snapshot.envelope + ?.directRevalidationOwner != null && + snapshot.envelope !== baseline.envelope, + ), + ) + } + revalidationReplacementReservedBeforeTail = replacement + if ( + currentSatisfiesDemand || + exactOwnerCoversCurrentEpoch || + replacement != null + ) { + if ( + baseline.envelope != null && + baseline.plan.servesResident + ) { + deliverDataLocked( + baseline.envelope, + revision = baseline.revision, + authority = DataDeliveryAuthority.CollectorBaseline, + ) + } else { + emitLoadingLocked() + } + } + } else if ( + collectorPlan.eligibleEnvelope == null || + !collectorPlan.plan.servesResident + ) { + emitLoadingLocked() + } + } + + else -> { + if ( + collectorPlan.eligibleEnvelope == null || + !collectorPlan.plan.servesResident + ) { + emitLoadingLocked() + } + } + } + + val outcome = candidate.outcome.await() + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + when (outcome) { + is FetchOutcome.Committed -> { + installCommittedWaitLocked(candidate, outcome) + break@replacementClassification + } + + is FetchOutcome.Revalidated -> { + if (revalidationReplacementReservedBeforeTail != null) { + effectiveTicket = revalidationReplacementReservedBeforeTail + initialPublicDeliveryCompleted = true + continue@replacementClassification + } + if (snapshot.envelope !== outcome.envelope) { + effectiveTicket = + if (plan is FetchPlan.Skip) { + null + } else { + ensureFetchForCollector(collectorPlan) + } + continue@replacementClassification + } + when ( + val delivery = + deliverRevalidatedLocked( + outcome = outcome, + watchReplacement = false, + ) + ) { + RevalidatedDelivery.Delivered -> { + effectiveTicket = null + initialPublicDeliveryCompleted = true + } + + RevalidatedDelivery.Obsolete -> { + snapshot = residenceSnapshot() + collectorPlan = + collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + effectiveTicket = + if (plan is FetchPlan.Skip) { + null + } else { + ensureFetchForCollector(collectorPlan) + } + } + + is RevalidatedDelivery.Replacement -> { + effectiveTicket = delivery.ticket + initialPublicDeliveryCompleted = + delivery.publicDeliveryCompleted + } + } + } + + is FetchOutcome.Failed -> { + if (residenceAdvancedFrom(candidate, snapshot)) { + enqueueFailureHandoff( + SettledTicketHandoff( + ticket = candidate, + outcome = outcome, + servedStale = publicServedStale, + ), + ) + effectiveTicket = + if (plan is FetchPlan.Skip) { + null + } else { + ensureFetchForCollector(collectorPlan) + } + continue@replacementClassification + } + break@replacementClassification + } + + is FetchOutcome.Deleted -> { + handleOutcomeLocked(candidate, outcome, false) + effectiveTicket = null + initialPublicDeliveryCompleted = true + } + + FetchOutcome.ObsoleteRevalidation, + FetchOutcome.Superseded, + -> { + effectiveTicket = + if (plan is FetchPlan.Skip) { + null + } else { + ensureFetchForCollector(collectorPlan) + } + } + } + } + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot, memoryEnvelope, memoryRevision) + plan = collectorPlan.plan + // A commit can land after the earlier outcome snapshot. Recognize only this + // ticket's exact writer envelope before generic residence delivery so an absent + // start keeps its Loading -> causally observed Data contract. + val finalCommittedDisposition = + effectiveTicket?.disposition?.value as? FetchDisposition.Committed + if ( + awaitingCommitted == null && + finalCommittedDisposition != null && + snapshot.envelope?.matchesWriterAttribution( + finalCommittedDisposition.attribution.value, + finalCommittedDisposition.attribution, + ) == true + ) { + installCommittedWaitLocked( + checkNotNull(effectiveTicket), + finalCommittedDisposition, + ) + } + val currentResidencePlan = + planFor( + freshness = freshness, + snapshot = snapshot, + ) + val reservedBaselineCurrentPlan = + planFor( + freshness = freshness, + snapshot = snapshot, + envelope = reservedCollectorEnvelope, + ) + val memoryOverride = + canRestampEngineMemoryOrigin( + memoryEnvelope = memoryEnvelope, + memoryRevision = memoryRevision, + currentEnvelope = snapshot.envelope, + currentRevision = snapshot.revision, + ) + if (effectiveTicket != null) { + if (watchedTicket !== effectiveTicket) { + watchedTicket = effectiveTicket + servedStaleForWatchedTicket = publicServedStale + } + lastRevalidationRequestedRevision = effectiveTicket.requestRevision + } else if (awaitingCommitted == null) { + watchedTicket = null + } + when { + initialPublicDeliveryCompleted -> Unit + + replacementCommittingTailRetained -> Unit + + // The loop already emitted Loading when the final residence was absent or + // withheld. A policy-servable different row stays silent here until the + // retained effective-ticket watcher classifies its ordered tail. + replacementClassificationCapped -> Unit + + pendingSettledTailAtRecapture && + awaitingCommitted != null && + snapshot.envelope != null && + plan !is FetchPlan.Skip && + plan.servesResident -> Unit + + completedExactRevalidation != null && + completedExactRevalidationDelivery == null && + reservedPlan.servesResident && + reservedBaselineCurrentPlan.servesResident && + reservedCollectorEnvelope != null && + ticket?.residenceRevisionAtLaunch == ticket?.requestRevision && + revalidatedSatisfiesDemand(snapshot, currentResidencePlan) -> + deliverDataLocked( + envelope = reservedCollectorEnvelope, + revision = reservedCollectorRevision, + originOverride = + if ( + canRestampEngineMemoryOrigin( + memoryEnvelope = memoryEnvelope, + memoryRevision = memoryRevision, + currentEnvelope = reservedCollectorEnvelope, + currentRevision = reservedCollectorRevision, + ) + ) { + Origin.MEMORY + } else { + null + }, + authority = DataDeliveryAuthority.CollectorBaseline, + ) + + replacementCommitRetainedServableRow && awaitingCommitted != null -> Unit + + awaitingCommitted != null || + (completedExactRevalidation != null && + completedExactRevalidationDelivery == null) -> + emitLoadingLocked() + + collectorPlan.currentIsForeignOwner && + collectorPlan.eligibleEnvelope != null && + plan.servesResident -> + deliverDataLocked( + envelope = collectorPlan.eligibleEnvelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + originOverride = + if (collectorPlan.eligibleEnvelope === memoryEnvelope) { + Origin.MEMORY + } else { + null + }, + authority = DataDeliveryAuthority.CollectorBaseline, + ) + + collectorPlan.currentIsForeignOwner -> emitLoadingLocked() + + freshness == Freshness.LocalOnly && collectorPlan.eligibleEnvelope == null -> { + if (startupReaderFailure == null) { + if (snapshot.envelope == null) { + deliverAbsenceLocked(snapshot.revision) { + emitLocalOnlyMissingLocked() + } + } else { + emitLocalOnlyMissingLocked() + } + } + } + + collectorPlan.eligibleEnvelope != null && plan.servesResident -> + deliverDataLocked( + envelope = collectorPlan.eligibleEnvelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + originOverride = if (memoryOverride) Origin.MEMORY else null, + authority = + if ( + memoryOverride || + collectorPlan.eligibleEnvelope.directRevalidationOwner != null + ) { + DataDeliveryAuthority.CollectorBaseline + } else { + DataDeliveryAuthority.Generic + }, + ) + + else -> { + if (snapshot.envelope == null && startupReaderFailure == null) { + deliverAbsenceLocked(snapshot.revision) { emitLoadingLocked() } + } else { + emitLoadingLocked() + } + } + } + startupReaderFailure?.let { emitErrorLocked(it) } + if ( + !replacementClassificationCapped && + !replacementCommittingTailRetained && + awaitingCommitted == null && + effectiveTicket?.disposition?.value !is FetchDisposition.Revalidated && + effectiveTicket?.disposition?.value !is FetchDisposition.Committing && + effectiveTicket?.disposition?.value !is FetchDisposition.Committed + ) { + flushPendingFailureHandoffsLocked() + } + InitialDelivery(snapshot, plan, effectiveTicket) + } + + suspend fun deliverRevalidated( + outcome: FetchOutcome.Revalidated, + ): RevalidatedDelivery = + mutex.withLock { + deliverRevalidatedLocked(outcome, watchReplacement = false) + } + + /** Rechecks policy after a 304 because epochs and wall-clock age may advance meanwhile. */ + private suspend fun deliverRevalidatedLocked( + outcome: FetchOutcome.Revalidated, + watchReplacement: Boolean, + ): RevalidatedDelivery { + var snapshot = residenceSnapshot() + if (snapshot.envelope !== outcome.envelope) return RevalidatedDelivery.Obsolete + var plan = + planFor( + freshness = freshness, + snapshot = snapshot, + ) + var demandSatisfied = revalidatedSatisfiesDemand(snapshot, plan) + + var replacement: FetchTicket? = null + if (!demandSatisfied) { + replacement = + ensureFetchForCollector( + CollectorFetchPlan( + eligibleEnvelope = snapshot.envelope, + eligibleRevision = snapshot.revision, + plan = plan, + currentIsForeignOwner = false, + ), + ) + if (replacement != null) { + if (watchReplacement) { + watchTicketLocked(replacement) + } else { + watchedTicket = replacement + servedStaleForWatchedTicket = publicServedStale + lastRevalidationRequestedRevision = replacement.requestRevision + } + } + snapshot = residenceSnapshot() + if (snapshot.envelope !== outcome.envelope) { + return replacement?.let { + RevalidatedDelivery.Replacement( + ticket = it, + publicDeliveryCompleted = false, + ) + } + ?: RevalidatedDelivery.Obsolete + } + plan = + planFor( + freshness = freshness, + snapshot = snapshot, + ) + demandSatisfied = revalidatedSatisfiesDemand(snapshot, plan) + } + + if ( + replacement != null && + replacement.disposition.value !is FetchDisposition.InFlight + ) { + return RevalidatedDelivery.Replacement( + ticket = replacement, + publicDeliveryCompleted = false, + ) + } + + serverDeletionObserved = false + val envelope = checkNotNull(snapshot.envelope) + when { + demandSatisfied -> + if ( + !emitRevalidatedLocked( + outcome = outcome, + envelope = envelope, + projectionBase = + snapshot.projectionBase?.takeIf { captured -> + captured.envelope === outcome.envelope && + captured.revision == outcome.residenceRevision + }, + ) + ) { + return RevalidatedDelivery.Obsolete + } + + plan.servesResident -> + deliverDataLocked( + envelope, + revision = outcome.residenceRevision, + projectionBase = + snapshot.projectionBase?.takeIf { captured -> + captured.envelope === outcome.envelope && + captured.revision == outcome.residenceRevision + }, + authority = DataDeliveryAuthority.OwnerOutcome, + ) + else -> emitLoadingLocked() + } + return replacement?.let { + RevalidatedDelivery.Replacement( + ticket = it, + publicDeliveryCompleted = true, + ) + } + ?: RevalidatedDelivery.Delivered + } + + /** Publishes the exact owner's 304 as a lifecycle signal and adopts its fresh envelope. */ + private suspend fun emitRevalidatedLocked( + outcome: FetchOutcome.Revalidated, + envelope: ValueEnvelope, + projectionBase: ProjectionBase?, + ): Boolean { + if (overlay == null) { + producer.send(StoreResult.Revalidated(outcome.age)) + telemetry?.onServe(key, envelope.origin) + lastDataFingerprint = DataFingerprint(envelope) + lastConfirmedRevision = outcome.residenceRevision + publicHasValue = true + loadingVisible = false + localOnlyMissingEmitted = false + publicServedStale = false + servedStaleForWatchedTicket = false + terminalFailedDemand = null + lastRevalidationRequestedRevision = outcome.residenceRevision + return true + } + + beforeProjectionAuthorizationTestGate() + val authorizationBase = + projectionBase?.also { captured -> + check( + captured.envelope === envelope && + captured.revision == outcome.residenceRevision, + ) { + "A captured projection base must name the revalidated residence." + } + } ?: ProjectionBase(envelope, outcome.residenceRevision) + val authorization = + projectionAuthorization( + base = authorizationBase, + originOverride = null, + authority = DataDeliveryAuthority.OwnerOutcome, + ) + val previousAuthorization = projectionAuthorization + projectionAuthorization = authorization + val readiness = awaitProjectionReadiness(authorization) + if (readiness is ProjectionReadiness.Obsolete) { + revokeObsoleteProjectionAuthorization(authorization, previousAuthorization) + return false + } + val projection = (readiness as ProjectionReadiness.Ready).projection + val revalidatedOrigin = + when (projection) { + is Projection.Value -> { + val previousValue = + lastDataFingerprint?.let { fingerprint -> + if (fingerprint.isOverlaid) { + fingerprint.overlaidValue + } else { + fingerprint.envelope?.value + } + } + if (!publicHasValue || previousValue != envelope.value) { + deliverConfirmedDataLocked( + envelope = envelope, + revision = outcome.residenceRevision, + authority = DataDeliveryAuthority.OwnerOutcome, + ) + } else { + lastDataFingerprint = DataFingerprint(envelope) + lastConfirmedRevision = outcome.residenceRevision + publicHasValue = true + loadingVisible = false + localOnlyMissingEmitted = false + publicServedStale = false + servedStaleForWatchedTicket = false + } + envelope.origin + } + + is Projection.Overlaid -> { + renderOverlaidLocked(authorization, projection.value) + Origin.OVERLAY + } + + Projection.Absent -> { + emitLoadingLocked(preserveProjectionAuthorization = true) + null + } + } + revalidatedOrigin?.let { telemetry?.onServe(key, it) } + terminalFailedDemand = null + lastRevalidationRequestedRevision = outcome.residenceRevision + producer.send(StoreResult.Revalidated(outcome.age)) + return true + } + + private fun revalidatedSatisfiesDemand( + snapshot: ResidenceSnapshot, + plan: FetchPlan, + ): Boolean = this@KeyEngine.revalidatedSatisfiesDemand(freshness, snapshot, plan) + + private fun ReaderRecord.Row.snapshot( + resolution: ReaderResolution, + ): ResidenceSnapshot = + ResidenceSnapshot( + state = resolution.state, + envelope = envelope, + revision = residenceRevision, + status = resolution.status, + nowEpochMillis = resolution.nowEpochMillis, + projectionBase = resolution.projectionBase, + ) + + /** Keeps an equal late reader replay inside the demand already settled by a failure. */ + private fun failedDemandStillCovers(snapshot: ResidenceSnapshot): Boolean { + val failedTicket = terminalFailedDemand ?: return false + if (residenceAdvancedFrom(failedTicket, snapshot)) { + terminalFailedDemand = null + return false + } + lastRevalidationRequestedRevision = snapshot.revision + return true + } + + suspend fun clearInitialTicket(ticket: FetchTicket) { + mutex.withLock { + if (watchedTicket === ticket) watchedTicket = null + if (awaitingCommitted?.ticket === ticket) awaitingCommitted = null + if (handledCommittedTicket === ticket) handledCommittedTicket = null + } + } + + suspend fun retainCommittedTicket( + ticket: FetchTicket, + outcome: FetchOutcome.Committed, + ) { + mutex.withLock { retainCommittedTicketLocked(ticket, outcome) } + } + + suspend fun deliverTerminalError(exception: StoreException) { + mutex.withLock { emitErrorLocked(exception, servedStaleOverride = false) } + } + + suspend fun deliverTerminalOutcome(outcome: FetchOutcome) { + mutex.withLock { surfaceTerminalOutcomeLocked(outcome, servedStaleForTicket = false) } + } + + /** Starts the configured projection observer once, including before a MustBeFresh wait. */ + fun startProjectionObserver() { + val snapshots = projectionSnapshot ?: return + if (projectionObserverStarted) return + projectionObserverStarted = true + producer.launch { + snapshots.collect { snapshot -> + if ( + snapshot is ProjectionSnapshot.Ready || + snapshot is ProjectionSnapshot.Terminal + ) { + beforeProjectionDeliveryLockTestGate() + mutex.withLock { + beforeProjectionDeliveryTestGate() + deliverProjectionSnapshotLocked(snapshot) + afterProjectionDeliveryTestGate() + } + } + } + } + } + + fun start( + planningEpoch: Long, + initialTicket: FetchTicket?, + initialPlan: FetchPlan, + ) { + if (initialTicket != null) { + watchedTicket = initialTicket + if (initialPlan !is FetchPlan.Skip) { + lastRevalidationRequestedRevision = initialTicket.requestRevision + } + observeCommittedDisposition(initialTicket) + } + + producer.launch { + readerRecords.collect { record -> deliverRecord(record) } + } + startProjectionObserver() + producer.launch { + initialTicket?.let { ticket -> + val outcome = awaitTicketOutcome(ticket) + beforeTicketOutcomeDeliveryTestGate() + mutex.withLock { + deliverWatchedOutcomeLocked(ticket, outcome) + } + } + state.staleEpochsAfter(planningEpoch).collect { + mutex.withLock { + serverDeletionObserved = false + requestAndDeliverLocked( + forceRequest = true, + suppressResidentIfVisible = true, + ) + } + } + } + } + + private suspend fun deliverRecord(record: ReaderRecord) { + beforeReaderDeliveryLockTestGate(record) + mutex.withLock { + beforeReaderDeliveryTestGate() + if (record !is ReaderRecord.Failure) latestReaderRecord = record + deliverReaderRecordLocked(record) + } + } + + /** Resolves and delivers one pipeline notification without leaving the delivery mutex. */ + private suspend fun deliverReaderRecordLocked(record: ReaderRecord) { + val resolution = resolveCurrentRecord(record) ?: return + when (val resolved = resolution.record) { + is ReaderRecord.Failure -> emitErrorLocked(resolved.exception) + is ReaderRecord.Row -> + deliverRowLocked( + notification = record, + initialResolution = resolution, + ) + + is ReaderRecord.Absent -> + deliverAbsentLocked(record as ReaderRecord.Absent) + } + } + + /** Plans a current row, reserving work before delivery and rechecking after suspension. */ + private suspend fun deliverRowLocked( + notification: ReaderRecord, + initialResolution: ReaderResolution, + ) { + installCompletedCommittedWaitIfNeededLocked() + var resolution = initialResolution + var row = resolution.record as ReaderRecord.Row + suppressMissingUntilReaderRecovery = false + serverDeletionObserved = false + + val committedWait = awaitingCommitted + if (committedWait != null && !isCausallyCurrent(notification, committedWait)) return + val authorizedByCommittedWait = committedWait != null + if (committedWait != null) clearCommittedWaitLocked(committedWait) + + val observedTicket = watchedTicket + val observedOutcome = + observedTicket + ?.takeIf { it.outcome.isCompleted } + ?.let { awaitTicketOutcome(it) } + if ( + observedTicket != null && + row.envelope.directRevalidationOwner === observedTicket && + (notification as? ReaderRecord.Row)?.envelope?.value == row.envelope.value + ) { + // A replay mapped before this collector's 304 must not replace its exact fresh + // owner while the ordered watcher is still responsible for direct delivery. + return + } + var collectorPlan = + collectorPlanFor( + snapshot = row.snapshot(resolution), + eligibleBaseline = readerEligibleBaseline(notification, row.envelope), + ) + var rowPlan = collectorPlan.plan + var demandSatisfied = + envelopeSatisfiesDemand( + collectorPlan.eligibleEnvelope, + resolution.state, + rowPlan, + ) + if (demandSatisfied) { + terminalFailedDemand = null + lastRevalidationRequestedRevision = row.residenceRevision + } else { + failedDemandStillCovers(row.snapshot(resolution)) + } + + val settledTicket = observedTicket + val completedSettledOutcome = + observedOutcome?.takeIf { outcome -> + settledTicket?.let { ticket -> + if (outcome is FetchOutcome.Failed) { + residenceAdvancedFrom( + ticket, + row.snapshot(resolution), + ) + } else { + ticket.requestRevision != row.residenceRevision + } + } == true + } + if (completedSettledOutcome is FetchOutcome.Deleted) { + if (watchedTicket === settledTicket) watchedTicket = null + handleOutcomeLocked( + checkNotNull(settledTicket), + completedSettledOutcome, + servedStaleForWatchedTicket, + ) + serverDeletionObserved = false + } + + val pendingTicket = watchedTicket + val pendingSlot = resolution.state.fetch as? FetchSlot.InFlight + val durableWriterRowReady = + demandSatisfied && + ( + authorizedByCommittedWait || + pendingTicket?.let { + isOwnDurablyCommittedRow(notification, it) + } == true + ) + if ( + pendingTicket != null && + pendingTicket === settledTicket && + observedOutcome == null && + pendingSlot?.ticket !== pendingTicket && + !durableWriterRowReady + ) { + // Slot settlement can precede ordered persistence/bookkeeping tails. Retain every + // other row until the exact outcome can install its causal or replacement handoff. + return + } + + if ( + rowPlan !is FetchPlan.Skip && + !demandSatisfied && + settledTicket != null && + completedSettledOutcome != null && + completedSettledOutcome !is FetchOutcome.Deleted + ) { + val outcome = completedSettledOutcome + if (outcome !is FetchOutcome.Committed) { + val oldServedStale = servedStaleForWatchedTicket + if (outcome is FetchOutcome.Failed) { + enqueueFailureHandoff( + SettledTicketHandoff( + ticket = settledTicket, + outcome = outcome, + servedStale = oldServedStale, + ), + ) + } + val replacement = ensureFetchForCollector(collectorPlan) + if (replacement != null) { + watchTicketLocked(replacement) + } else if (watchedTicket === settledTicket) { + watchedTicket = null + } + + // The replacement reservation can suspend. Only the exact row that caused + // the handoff may now be served as refreshing. + val current = resolveCurrentRecord(notification) + val currentRow = current?.record as? ReaderRecord.Row + if (current == null || currentRow == null || !isSameResolvedRow(row, currentRow)) { + return + } + resolution = current + row = currentRow + collectorPlan = + collectorPlanFor( + snapshot = row.snapshot(resolution), + eligibleBaseline = readerEligibleBaseline(notification, row.envelope), + ) + rowPlan = collectorPlan.plan + demandSatisfied = + envelopeSatisfiesDemand( + collectorPlan.eligibleEnvelope, + resolution.state, + rowPlan, + ) + if (demandSatisfied) { + terminalFailedDemand = null + lastRevalidationRequestedRevision = row.residenceRevision + } else { + failedDemandStillCovers(row.snapshot(resolution)) + } + } + } + + if ( + rowPlan !is FetchPlan.Skip && + !demandSatisfied && + watchedTicket == null && + lastRevalidationRequestedRevision != row.residenceRevision + ) { + ensureFetchForCollector(collectorPlan)?.let(::watchTicketLocked) + + // ensureFetch can suspend while another observation wins. The original row is + // only deliverable if generation, residence revision, and content all survived. + val current = resolveCurrentRecord(notification) ?: return + val currentRow = current.record as? ReaderRecord.Row ?: return + if (!isSameResolvedRow(row, currentRow)) return + resolution = current + row = currentRow + collectorPlan = + collectorPlanFor( + snapshot = row.snapshot(resolution), + eligibleBaseline = readerEligibleBaseline(notification, row.envelope), + ) + rowPlan = collectorPlan.plan + demandSatisfied = + envelopeSatisfiesDemand( + collectorPlan.eligibleEnvelope, + resolution.state, + rowPlan, + ) + if (demandSatisfied) { + terminalFailedDemand = null + lastRevalidationRequestedRevision = row.residenceRevision + } else { + failedDemandStillCovers(row.snapshot(resolution)) + } + } + + val eligibleEnvelope = collectorPlan.eligibleEnvelope + when { + demandSatisfied && eligibleEnvelope != null -> + deliverDataLocked( + eligibleEnvelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + authority = + if (eligibleEnvelope.directRevalidationOwner != null) { + DataDeliveryAuthority.CollectorBaseline + } else { + DataDeliveryAuthority.Generic + }, + ) + + rowPlan.servesResident && eligibleEnvelope != null -> + deliverDataLocked( + eligibleEnvelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + authority = + if (eligibleEnvelope.directRevalidationOwner != null) { + DataDeliveryAuthority.CollectorBaseline + } else { + DataDeliveryAuthority.Generic + }, + ) + else -> emitLoadingLocked() + } + flushPendingFailureHandoffsLocked() + } + + /** Defers an absence already owned by an in-flight commit or authoritative delete outcome. */ + private suspend fun deliverAbsentLocked(notification: ReaderRecord.Absent) { + val pendingTicket = watchedTicket + // The authoritative Deleted outcome owns exact-absence projection and its terminal + // Missing error. A restarted reader can observe the committed null first; letting that + // record replan here would insert Loading between the optimistic projection and error. + if (pendingTicket?.disposition?.value == FetchDisposition.Deleted) return + if (pendingTicket != null && !pendingTicket.outcome.isCompleted) { + val committing = + pendingTicket.disposition.value as? FetchDisposition.Committing + if ( + committing != null && + notification.consumedAttribution !== committing.attribution && + notification.activeWriteAttributionAtObservation !== committing.attribution + ) { + return + } + } + installCompletedCommittedWaitIfNeededLocked() + val committedWait = awaitingCommitted + if (committedWait != null && !isCausallyCurrent(notification, committedWait)) return + if (committedWait != null) clearCommittedWaitLocked(committedWait) + suppressMissingUntilReaderRecovery = false + if (publicHasValue || freshness != Freshness.LocalOnly) { + deliverAbsenceLocked(notification.residenceRevision) { emitLoadingLocked() } + } + if (pendingFailureHandoffs.isNotEmpty()) { + flushPendingFailureHandoffsLocked() + } + failedDemandStillCovers(residenceSnapshot()) + requestAndDeliverLocked(forceRequest = false) + flushPendingFailureHandoffsLocked() + } + + private fun enqueueFailureHandoff(handoff: SettledTicketHandoff) { + if (pendingFailureHandoffs.none { it.ticket === handoff.ticket }) { + pendingFailureHandoffs.addLast(handoff) + } + } + + private suspend fun flushPendingFailureHandoffsLocked() { + while (pendingFailureHandoffs.isNotEmpty()) { + val handoff = pendingFailureHandoffs.removeFirst() + surfaceTerminalOutcomeLocked(handoff.outcome, handoff.servedStale) + } + } + + private suspend fun deliverCurrentPlanStateLocked() { + while (true) { + val snapshot = residenceSnapshot() + val collectorPlan = collectorPlanFor(snapshot) + val eligibleEnvelope = collectorPlan.eligibleEnvelope + val decision = + if (eligibleEnvelope != null && collectorPlan.plan.servesResident) { + deliverDataLocked( + eligibleEnvelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + authority = + if (eligibleEnvelope.directRevalidationOwner != null) { + DataDeliveryAuthority.CollectorBaseline + } else { + DataDeliveryAuthority.Generic + }, + ) + } else if (snapshot.envelope == null && startupReaderFailure == null) { + deliverAbsenceLocked(snapshot.revision) { emitLoadingLocked() } + } else { + emitLoadingLocked() + return + } + if (decision != DataDeliveryDecision.ObsoleteProjection) { + return + } + } + } + + private fun envelopeSatisfiesDemand( + envelope: ValueEnvelope?, + state: KeyState, + plan: FetchPlan, + ): Boolean { + if (envelope == null) return false + return when (freshness) { + Freshness.CachedOrFetch, + Freshness.StaleIfError, + Freshness.MustBeFresh, + -> + isEngineConfirmedEnvelope(envelope) && + envelope.meta != null && + envelope.staleEpochAtCommit >= state.staleEpoch + + Freshness.LocalOnly, + is Freshness.MaxAge, + -> plan is FetchPlan.Skip + } + } + + /** Authorizes only the exact writer row after durable return, never a CAS fallback. */ + private fun isOwnDurablyCommittedRow( + notification: ReaderRecord, + ticket: FetchTicket, + ): Boolean { + val row = notification as? ReaderRecord.Row ?: return false + val disposition = ticket.disposition.value as? FetchDisposition.Committed + ?: return false + if (row.envelope.value != disposition.attribution.value) return false + return row.consumedAttribution === disposition.attribution || + row.activeWriteAttributionAtObservation === disposition.attribution + } + + /** Installs a completed commit before the current reader notification is classified. */ + private suspend fun installCompletedCommittedWaitIfNeededLocked() { + val ticket = watchedTicket ?: return + if (handledCommittedTicket === ticket) return + if (!ticket.outcome.isCompleted) return + val outcome = awaitTicketOutcome(ticket) + if (outcome !is FetchOutcome.Committed) return + + installCommittedWaitLocked(ticket, outcome) + } + + /** True only when this raw observation belongs at or after the completed write. */ + private fun isCausallyCurrent( + notification: ReaderRecord, + wait: CommittedReaderWait, + ): Boolean { + val rawSequence = + when (notification) { + is ReaderRecord.Row -> notification.rawObservationSequence + is ReaderRecord.Absent -> notification.rawObservationSequence + is ReaderRecord.Failure -> return false + } + if ( + notification.readerGen == wait.rawReaderGen && + rawSequence <= wait.rawCommitCutoff + ) { + return wait.authoritativeRawSequence != null && + rawSequence == wait.authoritativeRawSequence + } + return when (notification) { + is ReaderRecord.Row -> notification.successfulWriteSequenceAtObservation >= + wait.successfulWriteSequenceAtOutcome + + is ReaderRecord.Absent -> notification.successfulWriteSequenceAtObservation >= + wait.successfulWriteSequenceAtOutcome + + is ReaderRecord.Failure -> false + } + } + + private suspend fun installCommittedWaitLocked( + ticket: FetchTicket, + outcome: FetchOutcome.Committed, + ) { + installCommittedWaitLocked( + ticket = ticket, + successfulWriteSequence = outcome.successfulWriteSequence, + attribution = outcome.attribution, + rawReaderGen = outcome.rawReaderGen, + rawCommitCutoff = outcome.rawCommitCutoff, + authoritativeRawSequence = outcome.authoritativeRawSequence, + ) + } + + private suspend fun installCommittedWaitLocked( + ticket: FetchTicket, + disposition: FetchDisposition.Committed, + ) { + installCommittedWaitLocked( + ticket = ticket, + successfulWriteSequence = disposition.successfulWriteSequence, + attribution = disposition.attribution, + rawReaderGen = disposition.rawReaderGen, + rawCommitCutoff = disposition.rawCommitCutoff, + authoritativeRawSequence = disposition.authoritativeRawSequence, + ) + } + + private fun installCommittedWaitLocked( + ticket: FetchTicket, + successfulWriteSequence: Long, + attribution: AttributionTag, + rawReaderGen: Long, + rawCommitCutoff: Long, + authoritativeRawSequence: Long?, + ) { + handledCommittedTicket = ticket + awaitingCommitted = + CommittedReaderWait( + ticket = ticket, + successfulWriteSequenceAtOutcome = successfulWriteSequence, + attribution = attribution, + rawReaderGen = rawReaderGen, + rawCommitCutoff = rawCommitCutoff, + authoritativeRawSequence = authoritativeRawSequence, + ) + } + + private suspend fun retainCommittedTicketLocked( + ticket: FetchTicket, + outcome: FetchOutcome.Committed, + ) { + if (watchedTicket !== ticket) return + installCommittedWaitLocked(ticket, outcome) + when (val latest = latestReaderRecord) { + is ReaderRecord.Row, + is ReaderRecord.Absent, + -> deliverReaderRecordLocked(latest) + + null, + is ReaderRecord.Failure, + -> Unit + } + } + + private suspend fun reprocessLatestReaderRecordLocked() { + when (val latest = latestReaderRecord) { + is ReaderRecord.Row, + is ReaderRecord.Absent, + -> deliverReaderRecordLocked(latest) + + null, + is ReaderRecord.Failure, + -> Unit + } + } + + private fun clearCommittedWaitLocked(wait: CommittedReaderWait) { + if (awaitingCommitted === wait) awaitingCommitted = null + if (wait.ticket.outcome.isCompleted) { + if (watchedTicket === wait.ticket) watchedTicket = null + if (handledCommittedTicket === wait.ticket) handledCommittedTicket = null + } + } + + private suspend fun requestAndDeliverLocked( + forceRequest: Boolean, + suppressResidentIfVisible: Boolean = false, + ) { + if (awaitingCommitted != null) return + val observedTicket = watchedTicket + val observedOutcome = + observedTicket + ?.takeIf { it.outcome.isCompleted } + ?.let { awaitTicketOutcome(it) } + if (observedOutcome != null) { + if (observedOutcome is FetchOutcome.Committed) { + retainCommittedTicketLocked(checkNotNull(observedTicket), observedOutcome) + if (awaitingCommitted != null) return + } else { + return + } + } + var snapshot = residenceSnapshot() + var collectorPlan = collectorPlanFor(snapshot) + var plan = collectorPlan.plan + if (forceRequest) { + terminalFailedDemand = null + } else if ( + plan is FetchPlan.Skip || + envelopeSatisfiesDemand( + collectorPlan.eligibleEnvelope, + snapshot.state, + plan, + ) + ) { + terminalFailedDemand = null + } else { + failedDemandStillCovers(snapshot) + } + + if (freshness == Freshness.LocalOnly) { + val envelope = collectorPlan.eligibleEnvelope + if (envelope != null) { + deliverDataLocked( + envelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + authority = + if (envelope.directRevalidationOwner != null) { + DataDeliveryAuthority.CollectorBaseline + } else { + DataDeliveryAuthority.Generic + }, + ) + } else if (!suppressMissingUntilReaderRecovery) { + if (overlay != null && snapshot.envelope == null) { + deliverAbsenceLocked(snapshot.revision) { + if (publicHasValue) emitLoadingLocked() + emitLocalOnlyMissingLocked() + } + } else { + if (publicHasValue) emitLoadingLocked() + emitLocalOnlyMissingLocked() + } + } + return + } + + var ticket: FetchTicket? = null + if ( + plan !is FetchPlan.Skip && + watchedTicket == null && + !(serverDeletionObserved && snapshot.envelope == null) && + (forceRequest || lastRevalidationRequestedRevision != snapshot.revision) + ) { + ticket = ensureFetchForCollector(collectorPlan) + ticket?.let(::watchTicketLocked) + snapshot = residenceSnapshot() + collectorPlan = collectorPlanFor(snapshot) + plan = collectorPlan.plan + } + + val eligibleEnvelope = collectorPlan.eligibleEnvelope + when { + eligibleEnvelope != null && plan.servesResident -> { + val sameResidenceAlreadyVisible = + publicHasValue && + lastDataFingerprint == DataFingerprint(eligibleEnvelope) + if (!suppressResidentIfVisible || !sameResidenceAlreadyVisible) { + deliverDataLocked( + eligibleEnvelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + authority = + if (eligibleEnvelope.directRevalidationOwner != null) { + DataDeliveryAuthority.CollectorBaseline + } else { + DataDeliveryAuthority.Generic + }, + ) + } else { + refreshVisibleStaleOwnershipLocked() + } + } + + eligibleEnvelope != null && plan is FetchPlan.Skip -> + deliverDataLocked( + eligibleEnvelope, + revision = collectorPlan.eligibleRevision, + projectionBase = collectorPlan.eligibleProjectionBase, + authority = + if (eligibleEnvelope.directRevalidationOwner != null) { + DataDeliveryAuthority.CollectorBaseline + } else { + DataDeliveryAuthority.Generic + }, + ) + + plan !is FetchPlan.Skip -> emitLoadingLocked() + else -> emitLocalOnlyMissingLocked() + } + + ticket?.let(::watchTicketLocked) + } + + private fun watchTicketLocked(ticket: FetchTicket) { + // ensureFetch may join work launched against an older residence. Associate this + // collector with the actual ticket boundary so a newer revision can replan later. + terminalFailedDemand = null + lastRevalidationRequestedRevision = ticket.requestRevision + if (watchedTicket === ticket) return + watchedTicket = ticket + servedStaleForWatchedTicket = publicServedStale + observeCommittedDisposition(ticket) + producer.launch { + val outcome = awaitTicketOutcome(ticket) + beforeTicketOutcomeDeliveryTestGate() + mutex.withLock { + deliverWatchedOutcomeLocked(ticket, outcome) + } + } + } + + /** Wakes a retained writer row at durable return, before ordered bookkeeping completes. */ + private fun observeCommittedDisposition(ticket: FetchTicket) { + producer.launch { + val terminal = + ticket.disposition.first { + it !is FetchDisposition.InFlight && + it !is FetchDisposition.Committing + } + val committed = terminal as? FetchDisposition.Committed ?: return@launch + mutex.withLock { + if ( + watchedTicket === ticket && + handledCommittedTicket !== ticket + ) { + installCommittedWaitLocked(ticket, committed) + reprocessLatestReaderRecordLocked() + } + } + } + } + + private suspend fun deliverWatchedOutcomeLocked( + ticket: FetchTicket, + outcome: FetchOutcome, + ) { + if (watchedTicket !== ticket) return + if ( + outcome is FetchOutcome.Committed && + handledCommittedTicket === ticket + ) { + if (awaitingCommitted?.ticket === ticket) return + watchedTicket = null + reprocessLatestReaderRecordLocked() + if (handledCommittedTicket === ticket) handledCommittedTicket = null + return + } + var servedStaleForTicket = servedStaleForWatchedTicket + if (outcome is FetchOutcome.Failed) { + val advancedResidence = residenceAdvancedFrom(ticket, residenceSnapshot()) + if (advancedResidence) { + enqueueFailureHandoff( + SettledTicketHandoff( + ticket = ticket, + outcome = outcome, + servedStale = servedStaleForTicket, + ), + ) + } + reprocessLatestReaderRecordLocked() + if (watchedTicket !== ticket) return + if (advancedResidence) { + watchedTicket = null + val snapshot = residenceSnapshot() + val collectorPlan = collectorPlanFor(snapshot) + val plan = collectorPlan.plan + if (plan !is FetchPlan.Skip) { + val replacement = ensureFetchForCollector(collectorPlan) + replacement?.let(::watchTicketLocked) + if (replacement == null) { + deliverCurrentPlanStateLocked() + flushPendingFailureHandoffsLocked() + } + } else { + flushPendingFailureHandoffsLocked() + } + return + } + if (pendingFailureHandoffs.isNotEmpty()) { + deliverCurrentPlanStateLocked() + flushPendingFailureHandoffsLocked() + servedStaleForTicket = servedStaleForWatchedTicket + } + } + if (outcome !is FetchOutcome.Committed) watchedTicket = null + handleOutcomeLocked(ticket, outcome, servedStaleForTicket) + if ( + outcome !is FetchOutcome.Committed && + outcome !is FetchOutcome.Failed + ) { + reprocessLatestReaderRecordLocked() + flushPendingFailureHandoffsLocked() + } + } + + private suspend fun handleOutcomeLocked( + ticket: FetchTicket, + outcome: FetchOutcome, + servedStaleForTicket: Boolean, + ) { + when (outcome) { + is FetchOutcome.Committed -> + retainCommittedTicketLocked(ticket, outcome) + + is FetchOutcome.Revalidated -> { + if ( + deliverRevalidatedLocked(outcome, watchReplacement = true) == + RevalidatedDelivery.Obsolete + ) { + requestAndDeliverLocked(forceRequest = true) + } + } + + FetchOutcome.ObsoleteRevalidation -> + requestAndDeliverLocked(forceRequest = true) + + is FetchOutcome.Failed -> { + surfaceTerminalOutcomeLocked(outcome, servedStaleForTicket) + val snapshot = residenceSnapshot() + if (residenceAdvancedFrom(ticket, snapshot)) { + requestAndDeliverLocked(forceRequest = false) + } else { + terminalFailedDemand = ticket + } + } + + is FetchOutcome.Deleted -> { + serverDeletionObserved = true + surfaceTerminalOutcomeLocked(outcome, servedStaleForTicket) + } + + FetchOutcome.Superseded -> requestAndDeliverLocked(forceRequest = true) + } + } + + private suspend fun surfaceTerminalOutcomeLocked( + outcome: FetchOutcome, + servedStaleForTicket: Boolean, + ) { + when (outcome) { + is FetchOutcome.Failed -> { + if (overlay != null) { + deliverCurrentPlanStateLocked() + } + emitErrorLocked( + outcome.exception, + servedStaleOverride = + if (overlay == null) servedStaleForTicket else publicServedStale, + ) + } + + is FetchOutcome.Deleted -> { + if (overlay == null) { + emitLoadingLocked() + } else { + val rendered = + deliverAbsenceLocked(outcome.residenceRevision) { + emitLoadingLocked() + } + if (rendered == DataDeliveryDecision.ObsoleteProjection) { + deliverCurrentPlanStateLocked() + } + } + emitErrorLocked(serverDeletedException(), servedStaleOverride = false) + } + + else -> error("Only terminal outcomes can be surfaced by this helper: $outcome") + } + } + + private suspend fun deliverDataLocked( + envelope: ValueEnvelope, + revision: Long, + projectionBase: ProjectionBase? = null, + originOverride: Origin? = null, + authority: DataDeliveryAuthority = DataDeliveryAuthority.Generic, + ): DataDeliveryDecision { + if (overlay == null) { + return deliverConfirmedDataLocked( + envelope = envelope, + revision = revision, + originOverride = originOverride, + authority = authority, + ) + } + val existingAuthorization = projectionAuthorization + if ( + envelope.directRevalidationOwner != null && + authority == DataDeliveryAuthority.Generic && + !( + publicHasValue && + existingAuthorization?.base?.envelope === envelope && + existingAuthorization.base.revision == revision + ) + ) { + refreshVisibleStaleOwnershipLocked() + return DataDeliveryDecision.ForeignDirectRevalidation + } + + beforeProjectionAuthorizationTestGate() + val authorizationBase = + projectionBase?.also { captured -> + check(captured.envelope === envelope && captured.revision == revision) { + "A captured projection base must name the delivered residence." + } + } ?: ProjectionBase(envelope, revision) + val authorization = + projectionAuthorization( + base = authorizationBase, + originOverride = originOverride, + authority = authority, + ) + val previousAuthorization = projectionAuthorization + projectionAuthorization = authorization + return when (val readiness = awaitProjectionReadiness(authorization)) { + is ProjectionReadiness.Ready -> + renderProjectionLocked( + authorization = authorization, + projection = readiness.projection, + ) + + ProjectionReadiness.Obsolete -> { + revokeObsoleteProjectionAuthorization(authorization, previousAuthorization) + DataDeliveryDecision.ObsoleteProjection + } + } + } + + /** Landed confirmed-value renderer, also used by pass-through projection intent. */ + private suspend fun deliverConfirmedDataLocked( + envelope: ValueEnvelope, + revision: Long, + originOverride: Origin? = null, + authority: DataDeliveryAuthority = DataDeliveryAuthority.Generic, + ): DataDeliveryDecision { + val fingerprint = + DataFingerprint( + envelope = envelope, + ) + if ( + envelope.directRevalidationOwner != null && + authority == DataDeliveryAuthority.Generic && + !(publicHasValue && lastDataFingerprint == fingerprint) + ) { + refreshVisibleStaleOwnershipLocked() + return DataDeliveryDecision.ForeignDirectRevalidation + } + val snapshot = state.value + val refreshing = snapshot.fetch is FetchSlot.InFlight + val data = + toData( + envelope = envelope, + freshness = freshness, + originOverride = originOverride, + refreshingOverride = refreshing, + ) + if (lastDataFingerprint == fingerprint && publicHasValue) { + lastConfirmedRevision = revision + publicServedStale = data.isStale && staleServingTolerated(freshness) + if (watchedTicket != null) { + servedStaleForWatchedTicket = publicServedStale + } + return DataDeliveryDecision.AlreadyVisible + } + producer.send(data) + telemetry?.onServe(key, data.origin) + lastDataFingerprint = fingerprint + lastConfirmedRevision = revision + publicHasValue = true + loadingVisible = false + localOnlyMissingEmitted = false + publicServedStale = data.isStale && staleServingTolerated(freshness) + if (watchedTicket != null) { + servedStaleForWatchedTicket = publicServedStale + } + return DataDeliveryDecision.Delivered + } + + /** Authorizes exact confirmed absence and renders only its matching ready projection. */ + private suspend fun deliverAbsenceLocked( + revision: Long, + absent: suspend () -> Unit, + ): DataDeliveryDecision { + if (overlay == null) { + absent() + return DataDeliveryDecision.Absent + } + val authorization = + projectionAuthorization( + base = ProjectionBase(envelope = null, revision = revision), + originOverride = null, + authority = DataDeliveryAuthority.Generic, + ) + val previousAuthorization = projectionAuthorization + projectionAuthorization = authorization + return when (val readiness = awaitProjectionReadiness(authorization)) { + is ProjectionReadiness.Ready -> + renderProjectionLocked( + authorization = authorization, + projection = readiness.projection, + ) + + ProjectionReadiness.Obsolete -> { + revokeObsoleteProjectionAuthorization(authorization, previousAuthorization) + DataDeliveryDecision.ObsoleteProjection + } + } + } + + /** Builds an exact authorization and targets any already-accepted matching generation. */ + private fun projectionAuthorization( + base: ProjectionBase, + originOverride: Origin?, + authority: DataDeliveryAuthority, + ): ProjectionAuthorization { + val currentBase = checkNotNull(projectionResidence).value.base + val authorizedBase = currentBase.takeIf { it.matches(base) } ?: base + val snapshot = checkNotNull(projectionSnapshot).value + val targetGeneration = + when (snapshot) { + is ProjectionSnapshot.Pending -> + snapshot.generation.takeIf { snapshot.base.matches(authorizedBase) } + + is ProjectionSnapshot.Ready -> + snapshot.generation.takeIf { snapshot.base.matches(authorizedBase) } + + is ProjectionSnapshot.Terminal -> snapshot.generation + ProjectionSnapshot.Uninitialized -> null + } ?: 0L + return ProjectionAuthorization( + base = authorizedBase, + originOverride = originOverride, + authority = authority, + targetGeneration = targetGeneration, + ) + } + + /** Waits on snapshot and residence flows only; no engine lock is reacquired. */ + private suspend fun awaitProjectionReadiness( + authorization: ProjectionAuthorization, + ): ProjectionReadiness { + val snapshots = checkNotNull(projectionSnapshot) + val residences = checkNotNull(projectionResidence) + while (true) { + val residence = residences.value + if (!residence.base.matches(authorization.base)) { + return ProjectionReadiness.Obsolete + } + val snapshot = snapshots.value + when (snapshot) { + is ProjectionSnapshot.Terminal -> + throw OverlayProjectionException(snapshot.failure) + + is ProjectionSnapshot.Pending -> + if (snapshot.base.matches(authorization.base)) { + authorization.targetGeneration = + maxOf(authorization.targetGeneration, snapshot.generation) + } + + is ProjectionSnapshot.Ready -> + if ( + snapshot.base.matches(authorization.base) && + snapshot.generation >= authorization.targetGeneration + ) { + authorization.targetGeneration = + maxOf(authorization.targetGeneration, snapshot.generation) + return ProjectionReadiness.Ready(snapshot.projection) + } + + ProjectionSnapshot.Uninitialized -> Unit + } + + val observedSnapshot = snapshot + val observedResidence = residence + beforeProjectionReadinessWaitTestGate() + combine(snapshots, residences) { nextSnapshot, nextResidence -> + nextSnapshot to nextResidence + }.first { (nextSnapshot, nextResidence) -> + nextSnapshot !== observedSnapshot || nextResidence !== observedResidence + } + } + } + + /** Applies one projection through the collector's retained policy/render context. */ + private suspend fun renderProjectionLocked( + authorization: ProjectionAuthorization, + projection: Projection, + ): DataDeliveryDecision = + when (projection) { + is Projection.Value -> + deliverConfirmedDataLocked( + envelope = checkNotNull(authorization.base.envelope), + revision = authorization.base.revision, + originOverride = authorization.originOverride, + authority = authorization.authority, + ) + + is Projection.Overlaid -> renderOverlaidLocked(authorization, projection.value) + Projection.Absent -> { + emitLoadingLocked(preserveProjectionAuthorization = true) + DataDeliveryDecision.Absent + } + } + + /** Renders overlay-created data and derives stale-error posture from the authorized base. */ + private suspend fun renderOverlaidLocked( + authorization: ProjectionAuthorization, + value: V, + ): DataDeliveryDecision { + val fingerprint = + DataFingerprint( + envelope = authorization.base.envelope, + overlaidValue = value, + isOverlaid = true, + ) + val baseServedStale = + authorization.base.envelope?.let { envelope -> + toData( + envelope = envelope, + freshness = freshness, + originOverride = authorization.originOverride, + ).isStale && staleServingTolerated(freshness) + } == true + val samePublicProjection = + publicHasValue && + lastDataFingerprint?.isOverlaid == true && + lastDataFingerprint?.overlaidValue == value + if (samePublicProjection) { + lastDataFingerprint = fingerprint + lastConfirmedRevision = authorization.base.revision + publicServedStale = baseServedStale + if (watchedTicket != null) servedStaleForWatchedTicket = publicServedStale + return DataDeliveryDecision.AlreadyVisible + } + val data = + StoreResult.Data( + value = value, + origin = Origin.OVERLAY, + age = Duration.ZERO, + isStale = false, + refreshing = state.value.fetch is FetchSlot.InFlight, + ) + producer.send(data) + telemetry?.onServe(key, Origin.OVERLAY) + lastDataFingerprint = fingerprint + lastConfirmedRevision = authorization.base.revision + publicHasValue = true + loadingVisible = false + localOnlyMissingEmitted = false + publicServedStale = baseServedStale + if (watchedTicket != null) servedStaleForWatchedTicket = publicServedStale + return DataDeliveryDecision.Delivered + } + + /** Reprojects an active authorization or applies the referential foreign-owner exception. */ + private suspend fun deliverProjectionSnapshotLocked(snapshot: ProjectionSnapshot) { + if (checkNotNull(projectionSnapshot).value !== snapshot) return + if (snapshot is ProjectionSnapshot.Terminal) { + throw OverlayProjectionException(snapshot.failure) + } + val ready = snapshot as? ProjectionSnapshot.Ready ?: return + val authorization = projectionAuthorization ?: return + val currentBase = checkNotNull(projectionResidence).value.base + if (!ready.base.matches(currentBase)) return + if (ready.generation < authorization.targetGeneration) return + when { + ready.base.matches(authorization.base) -> + renderProjectionLocked(authorization, ready.projection) + + ready.base.isConfirmFreshAuthorizationSuccessorOf(authorization.base) -> { + val successorAuthorization = + ProjectionAuthorization( + base = ready.base, + originOverride = authorization.originOverride, + authority = authorization.authority, + targetGeneration = + maxOf(authorization.targetGeneration, ready.generation), + ) + projectionAuthorization = successorAuthorization + renderProjectionLocked(successorAuthorization, ready.projection) + } + + publicHasValue && + ready.base.envelope?.directRevalidationOwner != null && + authorization.base.envelope != null && + ready.base.envelope.value === authorization.base.envelope.value -> + renderProjectionLocked(authorization, ready.projection) + } + } + + private fun revokeObsoleteProjectionAuthorization( + authorization: ProjectionAuthorization, + previousAuthorization: ProjectionAuthorization?, + ) { + if (projectionAuthorization !== authorization) return + val currentBase = checkNotNull(projectionResidence).value.base + if (currentBase.isConfirmFreshAuthorizationSuccessorOf(authorization.base)) return + val current = currentBase.envelope + projectionAuthorization = + previousAuthorization?.takeIf { previous -> + current?.directRevalidationOwner != null && + publicHasValue && + lastDataFingerprint?.envelope === previous.base.envelope && + lastConfirmedRevision == previous.base.revision + } + } + + private fun refreshVisibleStaleOwnershipLocked() { + val visibleEnvelope = lastDataFingerprint?.envelope + publicServedStale = + publicHasValue && + visibleEnvelope != null && + toData( + envelope = visibleEnvelope, + freshness = freshness, + ).isStale && + staleServingTolerated(freshness) + if (watchedTicket != null) { + servedStaleForWatchedTicket = publicServedStale + } + } + + private suspend fun emitLoadingLocked( + preserveProjectionAuthorization: Boolean = false, + ) { + if (!preserveProjectionAuthorization) projectionAuthorization = null + if (loadingVisible) return + producer.send(StoreResult.Loading()) + loadingVisible = true + publicHasValue = false + publicServedStale = false + servedStaleForWatchedTicket = false + lastDataFingerprint = null + } + + private suspend fun emitLocalOnlyMissingLocked() { + if (localOnlyMissingEmitted) return + producer.send( + StoreResult.Error( + error = localOnlyMissingException().error, + servedStale = false, + ), + ) + localOnlyMissingEmitted = true + publicHasValue = false + loadingVisible = false + publicServedStale = false + servedStaleForWatchedTicket = false + lastDataFingerprint = null + } + + private suspend fun emitErrorLocked( + exception: StoreException, + servedStaleOverride: Boolean? = null, + ) { + producer.send( + StoreResult.Error( + error = exception.error, + servedStale = servedStaleOverride ?: publicServedStale, + ), + ) + } + } + + /** Returns a value according to policy, hydrating persistence before planning on a miss. */ + internal suspend fun get(freshness: Freshness): V { + ensureOpen() + while (true) { + var snapshot = residenceSnapshot() + if (snapshot.envelope == null) snapshot = hydrateFromSot() + val envelope = snapshot.envelope + val plan = planFor(freshness, snapshot) + + if (plan is FetchPlan.Skip) { + return envelope?.let { serve(it.value, it.origin) } + ?: throw localOnlyMissingException() + } + + if ( + envelope != null && + plan.servesResident && + freshness != Freshness.StaleIfError + ) { + ensureFetch(freshness) + return serve(envelope.value, envelope.origin) + } + + val ticket = ensureFetch(freshness) ?: continue + val outcome = ticket.outcome.await() + beforeTicketOutcomeDeliveryTestGate() + when (outcome) { + is FetchOutcome.Committed -> + return serve(committedValue(outcome), outcome.attribution.origin) + + is FetchOutcome.Revalidated -> { + val snapshot = residenceSnapshot() + if (snapshot.envelope === outcome.envelope) { + val plan = + planFor( + freshness = freshness, + snapshot = snapshot, + ) + if (revalidatedSatisfiesDemand(freshness, snapshot, plan)) { + snapshot.envelope?.let { return serve(it.value, it.origin) } + } + } + } + + FetchOutcome.ObsoleteRevalidation -> Unit + + is FetchOutcome.Failed -> + if (freshness == Freshness.StaleIfError && envelope != null) { + return serve(envelope.value, envelope.origin) + } else { + throw outcome.exception + } + + FetchOutcome.Superseded -> throw supersededException() + is FetchOutcome.Deleted -> throw serverDeletedException() + } + } + } + + @Suppress("UNCHECKED_CAST") + private fun committedValue(outcome: FetchOutcome.Committed): V = outcome.value as V + + /** Reports one successful caller-context serve without altering the returned value. */ + private fun serve( + value: V, + origin: Origin, + ): V { + telemetry?.onServe(key, origin) + return value + } + + /** Renders nullable metadata conservatively while retaining saturating age behavior. */ + private fun toData( + envelope: ValueEnvelope, + freshness: Freshness, + originOverride: Origin? = null, + refreshingOverride: Boolean? = null, + ): StoreResult.Data { + val snapshot = state.value + val meta = envelope.meta + val age = elapsedAge(wallClock.nowEpochMillis(), meta) + val epochStale = envelope.staleEpochAtCommit < snapshot.staleEpoch + val ageStale = freshness is Freshness.MaxAge && meta != null && age > freshness.notOlderThan + return StoreResult.Data( + value = envelope.value, + origin = originOverride ?: envelope.origin, + age = age, + isStale = meta == null || epochStale || ageStale, + refreshing = refreshingOverride ?: (snapshot.fetch is FetchSlot.InFlight), + ) + } + + private fun fetchException(failure: Throwable): StoreException { + val message = + "Fetch failed for key '${keyId.namespace}/${keyId.canonicalId}': ${failure.message}. " + + "The fetcher threw; inspect the cause for the underlying failure." + return StoreException(StoreError.Fetch(message, failure), failure) + } + + private fun fetchResultException(failure: Throwable): StoreException { + val message = + "Fetch failed for key '${keyId.namespace}/${keyId.canonicalId}': ${failure.message}. " + + "The fetcher returned FetcherResult.Error; inspect the cause for the " + + "underlying failure." + return StoreException(StoreError.Fetch(message, failure), failure) + } + + private fun readerException(failure: Throwable): StoreException { + val message = + "Reading the source of truth failed for key " + + "'${keyId.namespace}/${keyId.canonicalId}': ${failure.message}. " + + "Durable data could not be observed; inspect the cause and retry the read." + return StoreException(StoreError.Persistence(message, failure), failure) + } + + private fun writeException(failure: Throwable): StoreException { + val message = + "Persisting the fetched value failed for key " + + "'${keyId.namespace}/${keyId.canonicalId}': ${failure.message}. " + + "The fetch succeeded but the source of truth rejected the write; inspect the " + + "cause and retry the read." + return StoreException(StoreError.Persistence(message, failure), failure) + } + + private fun writeHandleException(failure: Throwable): StoreException { + val message = + "Write-handle apply failed for key '${keyId.namespace}/${keyId.canonicalId}': " + + "${failure.message}. The source of truth rejected the write; state is unchanged. " + + "Inspect the cause and retry." + return StoreException( + error = StoreError.Persistence(message = message, cause = failure), + cause = failure, + ) + } + + private fun clearPersistenceException(failure: Throwable): StoreException { + val message = + "clear() failed for key '${keyId.namespace}/${keyId.canonicalId}': the source of " + + "truth delete threw: ${failure.message}. The row may still exist; inspect the " + + "cause and retry clear()." + return StoreException(StoreError.Persistence(message, failure), failure) + } + + /** Wraps a durable per-key maintenance failure with typed persistence context. */ + private fun maintenancePersistenceException( + operation: String, + failure: Throwable, + ): StoreException { + val message = + "Durable $operation failed for key '${keyId.namespace}/${keyId.canonicalId}': " + + "${failure.message}. The operation did not complete; retry it and inspect the " + + "cause for the underlying persistence failure." + return StoreException( + error = StoreError.Persistence(message = message, cause = failure), + cause = failure, + ) + } + + private fun serverDeletePersistenceException(failure: Throwable): StoreException { + val message = + "Applying the server-side deletion failed for key " + + "'${keyId.namespace}/${keyId.canonicalId}': the source of truth delete threw: " + + "${failure.message}. The row may still exist; the deletion was not applied — " + + "retry the read." + return StoreException(StoreError.Persistence(message, failure), failure) + } + + private fun supersededException(): StoreException { + val message = + "Could not return a value for key '${keyId.namespace}/${keyId.canonicalId}': clear() " + + "removed the key while its fetch was in flight, so the fetched value was " + + "discarded. The value is currently missing; retry the read to trigger a fresh " + + "fetch." + return StoreException(StoreError.Missing(key, message)) + } + + private fun serverDeletedException(): StoreException { + val message = + "Could not return a value for key '${keyId.namespace}/${keyId.canonicalId}': the " + + "fetcher reported that the server deleted this value, so the local copy was " + + "removed. Recreate the value upstream or treat Missing as the empty state." + return StoreException(StoreError.Missing(key, message)) + } + + private fun localOnlyMissingException(): StoreException { + val message = + "Could not return a value for key '${keyId.namespace}/${keyId.canonicalId}': " + + "Freshness.LocalOnly forbids fetching and no local value exists. Seed the key " + + "with another policy first or handle StoreError.Missing as the empty state." + return StoreException(StoreError.Missing(key, message)) + } + + private fun notModifiedWithoutValueException(): StoreException { + val message = + "Could not return a value for key '${keyId.namespace}/${keyId.canonicalId}': the " + + "fetcher returned FetcherResult.NotModified but no local value exists to " + + "revalidate. Return FetcherResult.Success with a full value when the client has " + + "no cached copy." + return StoreException(StoreError.Missing(key, message)) + } + + private fun ensureOpen() { + if (!engineJob.isActive) throw storeClosedException() + } + + private data class ResidenceSnapshot( + val state: KeyState, + val envelope: ValueEnvelope?, + val revision: Long, + val status: KeyStatus?, + val nowEpochMillis: Long, + val projectionBase: ProjectionBase?, + ) + + private data class PlannedFetchEffect( + val effect: KeyEffect, + val collectorEligibleResidence: ValueEnvelope?, + val collectorEligibleRevision: Long, + val plan: FetchPlan, + ) + + private data class FetchReservation( + val ticket: FetchTicket, + val collectorEligibleResidence: ValueEnvelope?, + val collectorEligibleRevision: Long, + val plan: FetchPlan, + ) + + /** Marks an engine-side raw-stamping defect so the adapter retry boundary rethrows it. */ + private class RawObservationFailure( + val engineFailure: Throwable, + ) : RuntimeException(engineFailure) + + /** Restarts the sole reader after discarding one unresolved committed-fence mismatch. */ + private class RestartRawReaderSession : RuntimeException() + + /** Converts self-originated flow cancellation into a terminal no-failure-contract breach. */ + private class ProjectionChangesFailure( + val projectionCause: CancellationException, + ) : RuntimeException(projectionCause) + + /** Raw observations made while one exact SoT write is active. */ + private sealed interface ActiveRawPhase { + data object Unobserved : ActiveRawPhase + + class OtherBeforeMatching( + val observation: RawWriteObservation, + ) : ActiveRawPhase + + class Matching( + val observation: RawWriteObservation, + val attribution: AttributionTag, + ) : ActiveRawPhase + + class OtherAfterMatching( + val matchingObservation: RawWriteObservation, + val observation: RawWriteObservation, + ) : ActiveRawPhase + } + + /** One source-ordered nullable adapter row captured before pipeline conflation. */ + private data class RawWriteObservation( + val readerGen: Long, + val rawSequence: Long, + val value: Any?, + val attributionAtObservation: AttributionTag?, + val activeWriteAttributionAtObservation: AttributionTag?, + val successfulWriteSequenceAtObservation: Long, + ) { + /** Returns only the exact active writer tag under the value-bound fallback rule. */ + fun matchingWriterAttribution(): AttributionTag? { + val active = activeWriteAttributionAtObservation ?: return null + val observed = attributionAtObservation + return when { + value == null -> null + observed != null -> + active.takeIf { observed === active && observed.value == value } + active.value == value -> active + else -> null + } + } + } + + private fun ActiveRawPhase.matchingObservationOrNull(): RawWriteObservation? = + when (this) { + ActiveRawPhase.Unobserved -> null + is ActiveRawPhase.OtherBeforeMatching -> null + is ActiveRawPhase.Matching -> observation + is ActiveRawPhase.OtherAfterMatching -> matchingObservation + } + + private data class WriteObservationBoundary( + val readerGen: Long, + val observedAttribution: AttributionTag?, + val activeAttribution: AttributionTag?, + val successfulSequence: Long, + val latestRawSequence: Long, + val activeRawPhase: ActiveRawPhase, + val readerSession: Long, + val readerSessionActive: Boolean, + val pendingWriteAttribution: AttributionTag?, + ) + + private data class ClosedWriteBoundary( + val readerGen: Long, + val rawCommitCutoff: Long, + val phase: ActiveRawPhase, + val successfulWriteSequence: Long, + ) + + private data class DurableWriteResolution( + val successfulWriteSequence: Long, + val readerGen: Long, + val rawCommitCutoff: Long, + val authoritativeRawSequence: Long?, + ) + + private data class RawCommitResolution( + val readerGen: Long, + val rawCommitCutoff: Long, + val authoritativeRawSequence: Long?, + val residenceRevision: Long, + val envelope: ValueEnvelope?, + val consumedAttribution: AttributionTag?, + ) + + private data class PreparedReaderRow( + val consumedAttribution: AttributionTag?, + val ownerAttribution: AttributionTag, + val matchingAttribution: AttributionTag?, + val dropNonmatchingOnCommit: Boolean, + ) + + private data class ReaderResolution( + val record: ReaderRecord, + val state: KeyState, + val status: KeyStatus?, + val nowEpochMillis: Long, + val projectionBase: ProjectionBase?, + ) + + private data class InitialDelivery( + val snapshot: ResidenceSnapshot, + val plan: FetchPlan, + val ticket: FetchTicket?, + ) + + private data class CollectorFetchPlan( + val eligibleEnvelope: ValueEnvelope?, + val eligibleRevision: Long, + val plan: FetchPlan, + val currentIsForeignOwner: Boolean, + val eligibleProjectionBase: ProjectionBase? = null, + ) + + private data class TicketLaunchBaseline( + val envelope: ValueEnvelope?, + val revision: Long, + val plan: FetchPlan, + ) + + private data class EligibleBaseline( + val envelope: ValueEnvelope?, + val revision: Long, + ) + + private class ProjectionAuthorization( + val base: ProjectionBase, + val originOverride: Origin?, + val authority: DataDeliveryAuthority, + var targetGeneration: Long, + ) + + private data class TicketLaunchBaselineEntry( + val ticket: FetchTicket, + val baseline: TicketLaunchBaseline, + ) + + private enum class DataDeliveryAuthority { + Generic, + CollectorBaseline, + OwnerOutcome, + } + + private enum class DataDeliveryDecision { + Delivered, + AlreadyVisible, + ForeignDirectRevalidation, + Absent, + ObsoleteProjection, + } + + private sealed interface ProjectionReadiness { + class Ready( + val projection: Projection, + ) : ProjectionReadiness + + data object Obsolete : ProjectionReadiness + } + + private sealed interface RevalidatedDelivery { + data object Delivered : RevalidatedDelivery + + data object Obsolete : RevalidatedDelivery + + data class Replacement( + val ticket: FetchTicket, + val publicDeliveryCompleted: Boolean, + ) : RevalidatedDelivery + } + + private data class SettledTicketHandoff( + val ticket: FetchTicket, + val outcome: FetchOutcome, + val servedStale: Boolean, + ) + + private data class CommittedReaderWait( + val ticket: FetchTicket, + val successfulWriteSequenceAtOutcome: Long, + val attribution: AttributionTag, + val rawReaderGen: Long, + val rawCommitCutoff: Long, + val authoritativeRawSequence: Long?, + ) + + private data class DataFingerprint( + val envelope: ValueEnvelope?, + val overlaidValue: V? = null, + val isOverlaid: Boolean = false, + ) +} + +/** Opaque causal identity preserved only across metadata-only residence successors. */ +private class ProjectionAuthorizationLineage + +/** Exact residence identity and revision used by one projection attempt. */ +private class ProjectionBase( + val envelope: ValueEnvelope?, + val revision: Long, + val authorizationLineage: ProjectionAuthorizationLineage? = null, +) { + fun matches(other: ProjectionBase): Boolean = + envelope === other.envelope && revision == other.revision + + fun isConfirmFreshAuthorizationSuccessorOf(previous: ProjectionBase): Boolean { + val currentEnvelope = envelope ?: return false + val previousEnvelope = previous.envelope ?: return false + val lineage = authorizationLineage ?: return false + if (lineage !== previous.authorizationLineage) return false + if (currentEnvelope === previousEnvelope) return false + if (revision <= previous.revision) return false + if (currentEnvelope.value != previousEnvelope.value) return false + if (currentEnvelope.origin != previousEnvelope.origin) return false + if (currentEnvelope.meta == null || currentEnvelope.meta === previousEnvelope.meta) { + return false + } + if (currentEnvelope.staleEpochAtCommit < previousEnvelope.staleEpochAtCommit) return false + return currentEnvelope.directRevalidationOwner == null + } +} + +/** Immediate latest-residence signal used to obsolete readiness waits without an engine lock. */ +private class ProjectionResidence( + val base: ProjectionBase, +) + +/** Latest state of the one engine-owned projection writer. */ +private sealed interface ProjectionSnapshot { + data object Uninitialized : ProjectionSnapshot + + class Pending( + val base: ProjectionBase, + val generation: Long, + ) : ProjectionSnapshot + + class Ready( + val base: ProjectionBase, + val generation: Long, + val projection: Projection, + ) : ProjectionSnapshot + + class Terminal( + val generation: Long, + val failure: Throwable, + ) : ProjectionSnapshot +} + +/** MEMORY is honest only while the exact envelope and its monotone revision remain current. */ +internal fun canRestampMemoryOrigin( + memoryEnvelope: ValueEnvelope?, + memoryRevision: Long, + currentEnvelope: ValueEnvelope?, + currentRevision: Long, +): Boolean = + memoryEnvelope != null && + currentEnvelope === memoryEnvelope && + currentRevision == memoryRevision diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyId.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyId.kt new file mode 100644 index 000000000..38cb4b310 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyId.kt @@ -0,0 +1,23 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Canonical registry identity for a store key. + * + * Keeping namespace and canonical identifier as separate fields avoids relying on a key + * implementation's equality contract and avoids collisions caused by string concatenation. + */ +internal data class KeyId( + val namespace: String, + val canonicalId: String, +) { + companion object { + /** Captures the canonical identity exposed by [key]. */ + fun from(key: StoreKey): KeyId = + KeyId( + namespace = key.namespace.value, + canonicalId = key.canonicalId(), + ) + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt new file mode 100644 index 000000000..1502a1d1c --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistry.kt @@ -0,0 +1,192 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Owns at most one [KeyEngine] per canonical [KeyId], refcounted, with quiescent-only LRU idling. + * + * Structure: + * - [active] holds engines with refCount >= 1 or a not-yet-idled zero-ref engine awaiting its + * fetch reference release; [idle] holds only quiescent zero-ref engines in LRU (insertion) + * order. The maps are disjoint. Eviction reads only [idle], and acquisition removes from + * [idle] under the same lock, so evicting a held engine is unrepresentable. + * - References: every withEngine bracket (stream collection lifetime, get/maintenance call + * duration), every bulk-maintenance sweep entry, and every fetch job (via + * [EngineResidencyHooks]) holds one reference. + * - Eviction destroys only derived state; durable truth lives in the source of truth and + * bookkeeper, so a recreated engine is semantically identical: hydration restamps freshness + * from the bookkeeper's persisted status. Total resident engines <= active references + maxIdle. + * - Bulk sweeps retain each snapshotted engine for the action's duration, preserving the + * double-sweep-under-fence semantics: watermarks cover engines missed by a snapshot; an engine + * inserted between bulk-clear sweeps is included by purge or remains fenced until maintenance + * releases. + * - Creation still runs [verifyStableCanonicalId] once per residency. + */ +internal class KeyRegistry( + private val maxIdle: Int, + private val createEngine: (K, KeyId, EngineResidencyHooks) -> KeyEngine, +) { + private class Handle( + val engine: KeyEngine, + ) { + var refCount: Int = 0 + } + + private val lock = Mutex() + private val active = HashMap>() + private val idle = LinkedHashMap>() + private var closed = false + private var created = 0L + private var destroyed = 0L + + internal suspend fun withEngine( + key: K, + block: suspend (KeyEngine) -> R, + ): R { + val id = KeyId.from(key) + val handle = + lock.withLock { + if (closed) throw storeClosedException() + val resolved = + active[id] + ?: idle.remove(id)?.also { revived -> active[id] = revived } + ?: Handle(newEngine(key, id)).also { fresh -> + active[id] = fresh + created += 1 + } + resolved.refCount += 1 + resolved + } + try { + return block(handle.engine) + } finally { + withContext(NonCancellable) { release(id) } + } + } + + internal suspend fun forEachResident( + namespace: String?, + action: suspend (KeyEngine) -> Unit, + ) { + snapshotAndForEachResident(namespace, action) + } + + /** Returns the exact retained snapshot acted on when a caller must notify it after a fence. */ + internal suspend fun snapshotAndForEachResident( + namespace: String?, + action: suspend (KeyEngine) -> Unit, + ): List> { + val retained = + lock.withLock { + if (closed) return emptyList() + val ids = + (active.keys + idle.keys).filter { id -> + namespace == null || id.namespace == namespace + } + ids.map { id -> + val handle = active[id] ?: checkNotNull(idle.remove(id)).also { active[id] = it } + handle.refCount += 1 + id to handle + } + } + try { + retained.forEach { (_, handle) -> action(handle.engine) } + } finally { + withContext(NonCancellable) { retained.forEach { (id, _) -> release(id) } } + } + return retained.map { (_, handle) -> handle.engine } + } + + /** Releases one reference; at zero references a quiescent engine idles and overflow evicts. */ + private suspend fun release(id: KeyId) { + val overflow = + lock.withLock { + val handle = active[id] ?: return@withLock emptyList() + handle.refCount -= 1 + if (handle.refCount > 0) return@withLock emptyList() + // A non-quiescent zero-ref engine holds an in-flight fetch whose own reference + // release re-runs this check, so it deliberately stays active until then. + if (!handle.engine.isQuiescentForIdle()) return@withLock emptyList() + active.remove(id) + if (maxIdle == 0) { + destroyed += 1 + return@withLock listOf(handle.engine) + } + idle[id] = handle + val evicted = ArrayList>() + while (idle.size > maxIdle) { + val eldestId = idle.keys.first() + val eldest = idle.remove(eldestId) ?: break + destroyed += 1 + evicted += eldest.engine + } + evicted + } + overflow.forEach { engine -> engine.destroy() } + } + + /** + * Drops residency state at close. Every critical section is non-suspending, so the lock is + * effectively always free here; the caller also re-invokes this from the store job's + * completion handler. Present behavior: if a concurrent non-suspending section holds the lock + * at both attempts, the maps are released when the store becomes unreachable — entries are + * bounded by resident engines at close and every engine scope is already cancelled. + */ + internal fun clearOnClose() { + if (!lock.tryLock()) return + try { + closed = true + active.clear() + idle.clear() + } finally { + lock.unlock() + } + } + + internal suspend fun residentCountForTest(): Int = lock.withLock { active.size + idle.size } + + internal suspend fun idleCountForTest(): Int = lock.withLock { idle.size } + + internal suspend fun createdCountForTest(): Long = lock.withLock { created } + + internal suspend fun destroyedCountForTest(): Long = lock.withLock { destroyed } + + private fun newEngine( + key: K, + id: KeyId, + ): KeyEngine { + verifyStableCanonicalId(key, id) + return createEngine(key, id, HandleHooks(id)) + } + + private inner class HandleHooks(private val id: KeyId) : EngineResidencyHooks { + override suspend fun retainFetchRef() { + lock.withLock { + val handle = active[id] ?: idle.remove(id)?.also { active[id] = it } ?: return + handle.refCount += 1 + } + } + + override suspend fun releaseFetchRef() { + withContext(NonCancellable) { release(id) } + } + } + + private fun verifyStableCanonicalId( + key: K, + id: KeyId, + ) { + val second = key.canonicalId() + check(id.canonicalId == second) { + "StoreKey ${key::class.simpleName ?: ""} in namespace " + + "'${id.namespace}' returned two different canonical ids for the same " + + "instance ('${id.canonicalId}', then '$second'). canonicalId() is the key's " + + "durable identity and must be a pure, stable function of the key's immutable " + + "fields. Fix the key type so repeated calls always return the same string." + } + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyState.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyState.kt new file mode 100644 index 000000000..cdf7dc9f5 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/KeyState.kt @@ -0,0 +1,49 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreMeta + +/** + * Consume-once attribution owned by the exact fetch [owner] for a future pipeline emission. + * + * This tag applies only when a future pipeline row `==` [value]. A different or absent emission + * consumes and discards it; equal external content is indistinguishable residue. Matching writer + * rows remain outside residence until [owner] publishes an exact durable commit disposition. + */ +internal class AttributionTag( + val owner: FetchTicket, + val value: Any, + val origin: Origin, + val meta: StoreMeta, + val staleEpochAtCommit: Long, +) + +/** Immutable state governing fetch ownership and invalidation epochs for one canonical key. */ +internal data class KeyState( + /** The current fetch slot for the key. */ + val fetch: FetchSlot, + + /** Monotone count of invalidations; a resident value is stale when committed under a lower epoch. */ + val staleEpoch: Long, + + /** Monotone count of clears; guards fetch commits so a pre-clear response can never resurrect. */ + val clearEpoch: Long, + + /** Monotone generation of the future source-of-truth reader pipeline. */ + val readerGen: Long, + + /** Consume-once provenance for a future pipeline emission matching its stamped value. */ + val attribution: AttributionTag?, +) { + companion object { + /** State for a key with no active fetch and no invalidation history. */ + val Initial: KeyState = + KeyState( + fetch = FetchSlot.Idle, + staleEpoch = 0L, + clearEpoch = 0L, + readerGen = 0L, + attribution = null, + ) + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinator.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinator.kt new file mode 100644 index 000000000..8055c4262 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinator.kt @@ -0,0 +1,171 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext + +private const val REENTRY_MESSAGE = + "MaintenanceCoordinator callbacks cannot re-enter the same coordinator." + +private class MaintenanceCallbackContext( + val coordinator: MaintenanceCoordinator, + val parent: MaintenanceCallbackContext?, +) : AbstractCoroutineContextElement(Key) { + fun contains(candidate: MaintenanceCoordinator): Boolean { + var current: MaintenanceCallbackContext? = this + while (current != null) { + if (current.coordinator === candidate) return true + current = current.parent + } + return false + } + + companion object Key : CoroutineContext.Key +} + +internal class MaintenanceCoordinator { + private val stateMutex = Mutex() + private val maintenanceMutex = Mutex() + private val wakeVersion = MutableStateFlow(0L) + private val activeCommits = mutableMapOf() + private val blockedNamespaces = mutableSetOf() + private var globalMaintenance = false + + suspend fun withCommit( + namespace: String, + block: suspend () -> T, + ): T { + val callbackContext = callbackContextForEntry() + acquireCommit(namespace) + try { + return withContext(callbackContext) { block() } + } finally { + withContext(NonCancellable) { + releaseCommit(namespace) + } + } + } + + suspend fun withNamespaceMaintenance( + namespace: String, + block: suspend () -> T, + ): T = + withMaintenance( + namespace = namespace, + callbackContext = callbackContextForEntry(), + block = block, + ) + + suspend fun withGlobalMaintenance(block: suspend () -> T): T = + withMaintenance( + namespace = null, + callbackContext = callbackContextForEntry(), + block = block, + ) + + private suspend fun callbackContextForEntry(): MaintenanceCallbackContext { + val parent = currentCoroutineContext()[MaintenanceCallbackContext] + check(parent?.contains(this) != true) { REENTRY_MESSAGE } + return MaintenanceCallbackContext(coordinator = this, parent = parent) + } + + private suspend fun acquireCommit(namespace: String) { + while (true) { + var admitted = false + val observedVersion = + stateMutex.withLock { + if (!globalMaintenance && namespace !in blockedNamespaces) { + activeCommits[namespace] = activeCommits.getOrElse(namespace) { 0 } + 1 + advanceVersion() + admitted = true + } + wakeVersion.value + } + if (admitted) return + wakeVersion.first { it != observedVersion } + } + } + + private suspend fun releaseCommit(namespace: String) { + stateMutex.withLock { + val active = checkNotNull(activeCommits[namespace]) + if (active == 1) { + activeCommits.remove(namespace) + } else { + activeCommits[namespace] = active - 1 + } + advanceVersion() + } + } + + private suspend fun withMaintenance( + namespace: String?, + callbackContext: MaintenanceCallbackContext, + block: suspend () -> T, + ): T { + maintenanceMutex.lock() + var scopeBlocked = false + try { + blockScope(namespace) + scopeBlocked = true + awaitDrain(namespace) + return withContext(callbackContext) { block() } + } finally { + withContext(NonCancellable) { + if (scopeBlocked) { + unblockScope(namespace) + } + maintenanceMutex.unlock() + } + } + } + + private suspend fun blockScope(namespace: String?) { + stateMutex.withLock { + if (namespace == null) { + globalMaintenance = true + } else { + check(blockedNamespaces.add(namespace)) + } + advanceVersion() + } + } + + private suspend fun awaitDrain(namespace: String?) { + while (true) { + val observedVersion = + stateMutex.withLock { + val drained = + if (namespace == null) { + activeCommits.isEmpty() + } else { + namespace !in activeCommits + } + if (drained) null else wakeVersion.value + } + if (observedVersion == null) return + wakeVersion.first { it != observedVersion } + } + } + + private suspend fun unblockScope(namespace: String?) { + stateMutex.withLock { + if (namespace == null) { + globalMaintenance = false + } else { + check(blockedNamespaces.remove(namespace)) + } + advanceVersion() + } + } + + private fun advanceVersion() { + wakeVersion.value += 1L + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Projection.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Projection.kt new file mode 100644 index 000000000..393efccb9 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Projection.kt @@ -0,0 +1,25 @@ +package org.mobilenativefoundation.store6.core.internal + +/** The private overlay projection vocabulary retained by the engine writer. */ +internal sealed interface Projection { + /** Pass-through intent; collector-local authorization supplies the render context. */ + data class Value( + val envelope: ValueEnvelope, + ) : Projection + + /** A value created or changed by the overlay. */ + data class Overlaid( + val value: V, + ) : Projection + + /** Projected absence. */ + data object Absent : Projection +} + +/** Deterministic failure surfaced by every configured stream after projection terminalization. */ +internal class OverlayProjectionException( + cause: Throwable, +) : IllegalStateException( + "Overlay projection failed for this key; apply and changes are no-failure contracts.", + (cause as? OverlayProjectionException)?.cause ?: cause, + ) diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecord.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecord.kt new file mode 100644 index 000000000..9f7fee1e2 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecord.kt @@ -0,0 +1,108 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.StoreException + +/** One observation from the shared source-of-truth reader pipeline. */ +internal sealed interface ReaderRecord { + val readerGen: Long + val residenceRevision: Long + + /** The reader observed [envelope] and installed it at [residenceRevision]. */ + class Row( + val envelope: ValueEnvelope, + override val readerGen: Long, + override val residenceRevision: Long, + /** Successful-write sequence observed before this adapter event entered conflation. */ + val successfulWriteSequenceAtObservation: Long = 0L, + /** Exact consume-once tag consumed while mapping this notification, when any. */ + val consumedAttribution: AttributionTag? = null, + /** Exact commit whose SoT write was active when this adapter event was observed. */ + val activeWriteAttributionAtObservation: AttributionTag? = null, + /** Monotone source-order token captured before adapter-event conflation. */ + val rawObservationSequence: Long = 0L, + ) : ReaderRecord + + /** The reader observed absence and installed it at [residenceRevision]. */ + class Absent( + override val readerGen: Long, + override val residenceRevision: Long, + /** Successful-write sequence observed before this adapter event entered conflation. */ + val successfulWriteSequenceAtObservation: Long = 0L, + /** Exact consume-once tag consumed while mapping this notification, when any. */ + val consumedAttribution: AttributionTag? = null, + /** Exact commit whose SoT write was active when this adapter event was observed. */ + val activeWriteAttributionAtObservation: AttributionTag? = null, + /** Monotone source-order token captured before adapter-event conflation. */ + val rawObservationSequence: Long = 0L, + ) : ReaderRecord + + /** The reader failed without changing residence. */ + class Failure( + val exception: StoreException, + override val readerGen: Long, + override val residenceRevision: Long, + ) : ReaderRecord +} + +/** Adapter-terminal event; engine mapping happens downstream so its failures are never retried. */ +internal sealed interface RawReaderEvent { + class Row( + val value: V?, + /** Reader generation whose upstream adapter produced this observation. */ + val readerGen: Long, + /** Monotone source-order token assigned before conflation. */ + val rawObservationSequence: Long, + /** Exact consume-once tag that was current when this adapter event was observed. */ + val attributionAtObservation: AttributionTag?, + /** Successful-write sequence current when this adapter event was observed. */ + val successfulWriteSequenceAtObservation: Long, + /** Exact commit whose SoT write was active when this adapter event was observed. */ + val activeWriteAttributionAtObservation: AttributionTag?, + /** True when this nonmatching event followed a matching row from that active write. */ + val followedMatchingActiveWriteRow: Boolean, + /** True while a pre-return notification is fenced behind its committed writer-current. */ + val pendingCommitFenceAtObservation: Boolean, + ) : RawReaderEvent + + class Failure( + val exception: StoreException, + ) : RawReaderEvent +} + +/** + * Reconciles a queued reader record with live residence under the caller's state lock. + * + * A record is only a notification. Live residence remains authoritative when delivery is delayed. + */ +internal fun resolveCurrentRecord( + record: ReaderRecord, + currentReaderGen: Long, + currentResidence: ValueEnvelope?, + currentResidenceRevision: Long, +): ReaderRecord? { + if (record.readerGen != currentReaderGen) return null + return when (record) { + is ReaderRecord.Row -> { + val live = currentResidence ?: return null + if (live.value != record.envelope.value) return null + ReaderRecord.Row(live, currentReaderGen, currentResidenceRevision) + } + + is ReaderRecord.Absent -> { + if (currentResidence != null) return null + ReaderRecord.Absent(currentReaderGen, currentResidenceRevision) + } + + is ReaderRecord.Failure -> + ReaderRecord.Failure(record.exception, currentReaderGen, currentResidenceRevision) + } +} + +/** True only when a post-suspension row is the exact residence observation that was reserved. */ +internal fun isSameResolvedRow( + reserved: ReaderRecord.Row, + current: ReaderRecord.Row, +): Boolean = + current.readerGen == reserved.readerGen && + current.residenceRevision == reserved.residenceRevision && + current.envelope.value == reserved.envelope.value diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStore.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStore.kt new file mode 100644 index 000000000..69ed589eb --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStore.kt @@ -0,0 +1,218 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FreshnessValidator +import org.mobilenativefoundation.store6.core.seam.KeyEvents +import org.mobilenativefoundation.store6.core.seam.Overlay +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.StoreRuntime +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry +import org.mobilenativefoundation.store6.core.seam.WallClock + +/** + * Store implementation backed by one supervised [KeyEngine] per canonical key. + * + * Each engine receives its own supervised child scope. Closing the store cancels the parent job + * and all active engine work without allowing one key's fetch failure to cancel another key. + * Freshness policies are honored per the [Freshness] contract; planning is delegated to the + * engine's validator. + */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class RealStore( + fetcher: Fetcher, + private val sot: SourceOfTruth, + wallClock: WallClock, + private val bookkeeper: Bookkeeper, + validator: FreshnessValidator, + internal val telemetry: StoreTelemetry?, + private val overlay: Overlay?, + private val maxIdleKeys: Int, +) : Store { + private val storeJob = SupervisorJob() + private val storeScope = CoroutineScope(Dispatchers.Default + storeJob) + private val maintenanceCoordinator = MaintenanceCoordinator() + internal val events = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + internal val runtime: StoreRuntime = RealStoreRuntime(this) + private val registry = + KeyRegistry(maxIdleKeys) { key, id, hooks -> + val engineJob = SupervisorJob(storeJob) + KeyEngine( + key = key, + keyId = id, + fetcher = fetcher, + sot = sot, + bookkeeper = bookkeeper, + validator = validator, + wallClock = wallClock, + telemetry = telemetry, + overlay = overlay, + events = events, + engineScope = CoroutineScope(storeScope.coroutineContext + engineJob), + residencyHooks = hooks, + maintenanceCoordinator = maintenanceCoordinator, + ) + } + + init { + storeJob.invokeOnCompletion { registry.clearOnClose() } + } + + override fun stream( + key: K, + freshness: Freshness, + ): Flow> { + ensureOpen() + return flow { + ensureOpen() + registry.withEngine(key) { engine -> + emitAll(engine.stream(freshness)) + } + } + } + + override suspend fun get( + key: K, + freshness: Freshness, + ): V { + return withEngine(key) { engine -> engine.get(freshness) } + } + + override suspend fun invalidate(key: K) { + ensureOpen() + registry.withEngine(key) { engine -> engine.invalidate() } + } + + override suspend fun invalidateNamespace(namespace: StoreNamespace) { + ensureOpen() + durably("invalidateNamespace", "namespace '${namespace.value}'") { + bookkeeper.advanceStaleWatermark(namespace) + } + registry.forEachResident(namespace.value) { engine -> engine.invalidateResident() } + } + + override suspend fun invalidateAll() { + ensureOpen() + durably("invalidateAll", "all namespaces") { + bookkeeper.advanceGlobalStaleWatermark() + } + registry.forEachResident(namespace = null) { engine -> engine.invalidateResident() } + } + + override suspend fun clear(key: K) { + ensureOpen() + registry.withEngine(key) { engine -> engine.clear() } + } + + override suspend fun clearNamespace(namespace: StoreNamespace) { + ensureOpen() + val cleared = + maintenanceCoordinator.withNamespaceMaintenance(namespace.value) { + val firstSweep = + registry.snapshotAndForEachResident(namespace.value) { engine -> + engine.clearResident() + } + durably("clearNamespace", "namespace '${namespace.value}'") { + sot.deleteNamespace(namespace) + } + durably("clearNamespace", "namespace '${namespace.value}'") { + bookkeeper.forgetNamespace(namespace) + } + registry.forEachResident(namespace.value) { engine -> engine.clearResident() } + firstSweep + } + cleared.forEach { engine -> engine.notifyBulkClearCompleted() } + } + + override suspend fun clearAll() { + ensureOpen() + val cleared = + maintenanceCoordinator.withGlobalMaintenance { + val firstSweep = + registry.snapshotAndForEachResident(namespace = null) { engine -> + engine.clearResident() + } + durably("clearAll", "all namespaces") { sot.deleteAll() } + durably("clearAll", "all namespaces") { bookkeeper.forgetAll() } + registry.forEachResident(namespace = null) { engine -> engine.clearResident() } + firstSweep + } + cleared.forEach { engine -> engine.notifyBulkClearCompleted() } + } + + internal suspend fun withEngine( + key: K, + action: suspend (KeyEngine) -> R, + ): R { + ensureOpen() + return registry.withEngine(key, action) + } + + internal suspend fun residentEngineCountForTest(): Int = registry.residentCountForTest() + + internal suspend fun idleEngineCountForTest(): Int = registry.idleCountForTest() + + internal suspend fun createdEngineCountForTest(): Long = registry.createdCountForTest() + + internal suspend fun destroyedEngineCountForTest(): Long = registry.destroyedCountForTest() + + internal suspend fun awaitTerminationForTest() = storeJob.join() + + override fun close() { + storeJob.cancel(CancellationException(STORE_CLOSED_MESSAGE)) + registry.clearOnClose() + } + + /** Fails deterministically when an operation starts after [close]. */ + private fun ensureOpen() { + if (!storeJob.isActive) { + throw storeClosedException() + } + } + + /** Runs one durable maintenance step, preserving cancellation and typing other failures. */ + private suspend fun durably( + operation: String, + scope: String, + step: suspend () -> Unit, + ) { + try { + step() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + val message = + "$operation failed for $scope: ${failure.message}. Durable maintenance runs in " + + "a fixed order (stale mark before signal, delete before forget); completed " + + "steps remain applied and are conservative. Retry the operation and inspect " + + "the cause for the underlying persistence failure." + throw StoreException( + error = StoreError.Persistence(message = message, cause = failure), + cause = failure, + ) + } + } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStoreRuntime.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStoreRuntime.kt new file mode 100644 index 000000000..ab1388306 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/RealStoreRuntime.kt @@ -0,0 +1,38 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.seam.KeyEvents +import org.mobilenativefoundation.store6.core.seam.StoreRuntime +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry +import org.mobilenativefoundation.store6.core.seam.StoreWriteHandle + +/** The engine-backed capability handle; obtained only through the seam `runtime()` accessor. */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class RealStoreRuntime( + private val store: RealStore, +) : StoreRuntime, StoreWriteHandle { + override val writeHandle: StoreWriteHandle + get() = this + + override val keyEvents: Flow + get() = store.events + + override val telemetry: StoreTelemetry? + get() = store.telemetry + + override suspend fun apply( + key: K, + value: V, + ) = store.withEngine(key) { engine -> engine.applyWrite(value) } + + /** Routes through KeyEngine.invalidate, producing Invalidated events and telemetry. */ + override suspend fun markStale(key: K) = store.invalidate(key) + + override suspend fun confirmFresh( + key: K, + etag: String?, + ) = store.withEngine(key) { engine -> engine.confirmFresh(etag) } +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreLifecycle.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreLifecycle.kt new file mode 100644 index 000000000..564da0f22 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreLifecycle.kt @@ -0,0 +1,26 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CancellationException + +/** Stable diagnostic used when an operation is attempted after store closure. */ +internal const val STORE_CLOSED_MESSAGE: String = "Store is closed." + +/** Diagnostic for the internal cancellation that destroys an evicted quiescent engine. */ +internal const val ENGINE_EVICTED_MESSAGE: String = "Engine evicted after quiescence." + +/** Default bound on quiescent engine residency (StoreBuilder.maxIdleKeys). */ +internal const val DEFAULT_MAX_IDLE_ENGINES: Int = 128 + +/** Grace period the shared reader pipeline stays subscribed after its last collector leaves. */ +internal const val READER_PIPELINE_GRACE_MILLIS: Long = 100L + +/** Fixed defensive delay before retrying a failed reader subscription. */ +internal const val READER_RETRY_DELAY_MILLIS: Long = 100L + +/** Creates the deterministic failure for an operation that requires an open store. */ +internal fun storeClosedException(): IllegalStateException = + IllegalStateException(STORE_CLOSED_MESSAGE) + +/** Creates the cancellation used to terminate work that was active when the store closed. */ +internal fun storeClosedCancellation(): CancellationException = + CancellationException(STORE_CLOSED_MESSAGE) diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt new file mode 100644 index 000000000..87ed6f500 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/StoreResultFlows.kt @@ -0,0 +1,104 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.StoreResult + +/** + * Decouples Store result production from collection with a kind-bounded pending queue. + * + * The queue holds at most one pending element per [StoreResult] kind (≤ 4). The latest occurrence + * wins for each kind, and delivery order is the relative order of those latest occurrences. + * When pending results drain before the next same-kind emission, every emission is delivered. A + * blocked collector instead receives at least the latest pending result per kind. This realizes + * an O(1)-per-collector bound that covers lifecycle signals as well as data. + * + * A pathological fetch-error storm cannot grow a collector's buffer because the queue is + * kind-bounded. This operator bounds delivery buffering only and adds or changes no engine retry + * or backoff behavior. [StoreResult.Revalidated] is never conflated away in favor of another kind: + * only a newer Revalidated supersedes an older queued one for a blocked collector, so the kind is + * never lost. + */ +internal fun Flow>.conflateLatestData(): Flow> = flow { + var terminalFailure: Throwable? = null + + coroutineScope { + val pending = ArrayDeque>() + val mutex = Mutex() + val wakeVersion = MutableStateFlow(0L) + var upstreamComplete = false + var upstreamFailure: Throwable? = null + + val upstream = launch { + try { + this@conflateLatestData.collect { result -> + mutex.withLock { + pending.removeAll { queued -> sameKind(queued, result) } + pending.addLast(result) + wakeVersion.value += 1 + } + yield() + } + } catch (failure: Throwable) { + currentCoroutineContext().ensureActive() + mutex.withLock { upstreamFailure = failure } + } finally { + mutex.withLock { + upstreamComplete = true + wakeVersion.value += 1 + } + } + } + + try { + while (true) { + var next: StoreResult? = null + var complete = false + var failure: Throwable? = null + var observedWakeVersion = 0L + + mutex.withLock { + if (pending.isNotEmpty()) { + next = pending.removeFirst() + } else if (upstreamComplete) { + complete = true + failure = upstreamFailure + } else { + observedWakeVersion = wakeVersion.value + } + } + + when { + next != null -> emit(next!!) + complete -> { + terminalFailure = failure + break + } + else -> wakeVersion.first { it > observedWakeVersion } + } + } + } finally { + upstream.cancel() + } + } + + terminalFailure?.let { throw it } +} + +/** Two results share a kind when a newer one supersedes the older for a blocked collector. */ +private fun sameKind(a: StoreResult<*>, b: StoreResult<*>): Boolean = + when (a) { + is StoreResult.Data<*> -> b is StoreResult.Data<*> + is StoreResult.Loading -> b is StoreResult.Loading + is StoreResult.Revalidated -> b is StoreResult.Revalidated + is StoreResult.Error -> b is StoreResult.Error + } diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Transitions.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Transitions.kt new file mode 100644 index 000000000..f3c5d92ce --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/Transitions.kt @@ -0,0 +1,299 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreMeta + +/** An input applied to the immutable state for a canonical key. */ +internal sealed interface KeyEvent { + /** Requests a fetch, offering [fresh] as the ticket if no fetch is active. */ + class EnsureFetch( + val fresh: FetchTicket, + ) : KeyEvent + + /** Reports that the fetch represented by [ticket] produced [value] ready to commit. */ + class CommitFetch( + val ticket: FetchTicket, + val value: Any, + val meta: StoreMeta, + ) : KeyEvent + + /** Reports that the fetch represented by [ticket] revalidated the resident value. */ + class CommitRevalidated( + val ticket: FetchTicket, + ) : KeyEvent + + /** + * Reports that the write handle confirmed [value] for direct source-of-truth commit; [ticket] + * is the synthetic owner of the stamped SOT attribution. + */ + class ApplyWrite( + val ticket: FetchTicket, + val value: Any, + val meta: StoreMeta, + ) : KeyEvent + + /** Reports that the fetch represented by [ticket] observed a server-side deletion. */ + class CommitDeleted( + val ticket: FetchTicket, + ) : KeyEvent + + /** Reports that the fetch represented by [ticket] reached a terminal outcome. */ + class SettleFetch( + val ticket: FetchTicket, + ) : KeyEvent + + /** Marks the key stale without removing its value. */ + data object Invalidate : KeyEvent + + /** Destructively removes the key's value and supersedes any in-flight fetch commit. */ + data object Clear : KeyEvent + + /** Returns and clears [observed] only when it is still the current consume-once tag. */ + class ConsumeAttribution( + val observed: AttributionTag?, + ) : KeyEvent + + /** Revokes the current consume-once attribution tag. */ + data object RevokeAttribution : KeyEvent +} + +/** A side effect for the engine to interpret after a pure state transition. */ +internal sealed interface KeyEffect { + /** Launch a fetch owned by [ticket]. */ + class Launch( + val ticket: FetchTicket, + ) : KeyEffect + + /** Wait for the existing fetch represented by [ticket]. */ + class Join( + val ticket: FetchTicket, + ) : KeyEffect + + /** Accept the guarded fetch and stamp attribution; the engine persists, then converges it. */ + data object Commit : KeyEffect + + /** Refresh resident metadata inside the same critical section. */ + data object CommitRevalidation : KeyEffect + + /** Stamp SOT attribution; the engine writes the SoT and residence through under writeLock. */ + data object CommitWrite : KeyEffect + + /** Null out residence and forget bookkeeping: the server deleted the value. */ + data object CommitDelete : KeyEffect + + /** The fetch result was rejected because a clear advanced the clear epoch after launch. */ + data object Superseded : KeyEffect + + /** The key was marked stale; no residence change is required. */ + data object Invalidated : KeyEffect + + /** Null out residence inside the same critical section. */ + data object ClearResidence : KeyEffect + + /** The matching active fetch was settled. */ + data object Settled : KeyEffect + + /** The consume-once attribution returned by a consume event, if one was present. */ + class Consumed( + val tag: AttributionTag?, + ) : KeyEffect + + /** The consume-once attribution was revoked. */ + data object AttributionRevoked : KeyEffect + + /** The event did not apply to the current state. */ + data object Ignored : KeyEffect +} + +/** The immutable state and engine effect produced by applying one [KeyEvent]. */ +internal data class KeyTransition( + val state: KeyState, + val effect: KeyEffect, +) + +/** + * Applies [event] to [state] without performing suspension, I/O, or coroutine work. + * + * Ticket comparisons are identity checks so an older fetch can never commit into or settle a + * newer fetch's slot. A successful commit settles the slot before its ordered source-of-truth + * tail; another demand may reserve the next ticket, while the engine's write lock serializes both + * mutations and the settled ticket's disposition preserves exact attribution until its outcome is + * published. Epoch fields are monotone and are never reset by any event. + */ +internal fun transition( + state: KeyState, + event: KeyEvent, +): KeyTransition = + when (event) { + is KeyEvent.EnsureFetch -> + when (val slot = state.fetch) { + FetchSlot.Idle -> + KeyTransition( + state = state.copy( + fetch = FetchSlot.InFlight( + ticket = event.fresh, + clearEpochAtLaunch = state.clearEpoch, + ), + ), + effect = KeyEffect.Launch(event.fresh), + ) + + is FetchSlot.InFlight -> + KeyTransition(state = state, effect = KeyEffect.Join(slot.ticket)) + } + + is KeyEvent.CommitFetch -> + when (val slot = state.fetch) { + is FetchSlot.InFlight -> + when { + slot.ticket !== event.ticket -> + KeyTransition(state = state, effect = KeyEffect.Ignored) + + slot.clearEpochAtLaunch != state.clearEpoch -> + KeyTransition(state = state, effect = KeyEffect.Superseded) + + else -> + KeyTransition( + state = state.copy( + fetch = FetchSlot.Idle, + attribution = AttributionTag( + owner = event.ticket, + value = event.value, + origin = Origin.FETCHER, + meta = event.meta, + staleEpochAtCommit = state.staleEpoch, + ), + ), + effect = KeyEffect.Commit, + ) + } + + FetchSlot.Idle -> + KeyTransition(state = state, effect = KeyEffect.Ignored) + } + + is KeyEvent.CommitRevalidated -> + when (val slot = state.fetch) { + is FetchSlot.InFlight -> + when { + slot.ticket !== event.ticket -> + KeyTransition(state = state, effect = KeyEffect.Ignored) + + slot.clearEpochAtLaunch != state.clearEpoch -> + KeyTransition(state = state, effect = KeyEffect.Superseded) + + else -> + KeyTransition( + state = state.copy( + fetch = FetchSlot.Idle, + attribution = null, + ), + effect = KeyEffect.CommitRevalidation, + ) + } + + FetchSlot.Idle -> + KeyTransition(state = state, effect = KeyEffect.Ignored) + } + + is KeyEvent.ApplyWrite -> + KeyTransition( + state = state.copy( + attribution = AttributionTag( + owner = event.ticket, + value = event.value, + origin = Origin.SOT, + meta = event.meta, + staleEpochAtCommit = state.staleEpoch, + ), + ), + effect = KeyEffect.CommitWrite, + ) + + is KeyEvent.CommitDeleted -> + when (val slot = state.fetch) { + is FetchSlot.InFlight -> + when { + slot.ticket !== event.ticket -> + KeyTransition(state = state, effect = KeyEffect.Ignored) + + slot.clearEpochAtLaunch != state.clearEpoch -> + KeyTransition(state = state, effect = KeyEffect.Superseded) + + else -> + KeyTransition( + // staleEpoch deliberately unchanged: a deleted key is absent-and- + // satisfied, so streams are not driven into a refetch loop; the + // next demand fetches. clearEpoch advances because the removal is + // destructive. + state = state.copy( + fetch = FetchSlot.Idle, + clearEpoch = state.clearEpoch + 1, + readerGen = state.readerGen + 1, + attribution = null, + ), + effect = KeyEffect.CommitDelete, + ) + } + + FetchSlot.Idle -> + KeyTransition(state = state, effect = KeyEffect.Ignored) + } + + is KeyEvent.SettleFetch -> + when (val slot = state.fetch) { + is FetchSlot.InFlight -> + if (slot.ticket === event.ticket) { + KeyTransition( + state = state.copy(fetch = FetchSlot.Idle), + effect = + if (slot.clearEpochAtLaunch != state.clearEpoch) { + KeyEffect.Superseded + } else { + KeyEffect.Settled + }, + ) + } else { + KeyTransition(state = state, effect = KeyEffect.Ignored) + } + + FetchSlot.Idle -> + KeyTransition(state = state, effect = KeyEffect.Ignored) + } + + KeyEvent.Invalidate -> + KeyTransition( + state = state.copy(staleEpoch = state.staleEpoch + 1), + effect = KeyEffect.Invalidated, + ) + + KeyEvent.Clear -> + KeyTransition( + state = state.copy( + clearEpoch = state.clearEpoch + 1, + staleEpoch = state.staleEpoch + 1, + readerGen = state.readerGen + 1, + attribution = null, + ), + effect = KeyEffect.ClearResidence, + ) + + is KeyEvent.ConsumeAttribution -> { + val tag = state.attribution.takeIf { it === event.observed } + KeyTransition( + state = if (tag == null) state else state.copy(attribution = null), + effect = KeyEffect.Consumed(tag), + ) + } + + KeyEvent.RevokeAttribution -> + KeyTransition( + state = + if (state.attribution == null) { + state + } else { + state.copy(attribution = null) + }, + effect = KeyEffect.AttributionRevoked, + ) + } diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ValueEnvelope.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ValueEnvelope.kt new file mode 100644 index 000000000..e29765bae --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/ValueEnvelope.kt @@ -0,0 +1,24 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreMeta + +/** An immutable resident value paired with the provenance needed for honest emissions. */ +internal data class ValueEnvelope( + val value: V, + val origin: Origin, + + /** + * Freshness metadata recorded when this value was committed, or `null` when provenance is + * unknown (an external source-of-truth row or a hydrated pre-existing row). Null meta is a + * conservative posture: the value reports `isStale = true`, age zero, and never + * satisfies demand without a revalidation (see the null-meta planning rule). + */ + val meta: StoreMeta?, + + /** The key's stale epoch stamped when this envelope was produced. */ + val staleEpochAtCommit: Long, + + /** Ticket whose no-row 304 direct-send path exclusively owns this exact envelope identity. */ + val directRevalidationOwner: FetchTicket? = null, +) diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.kt new file mode 100644 index 000000000..3d55af8ef --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.kt @@ -0,0 +1,15 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.WallClock + +/** The production clock backed by each platform's system clock. */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal val SystemWallClock: WallClock = + object : WallClock { + override fun nowEpochMillis(): Long = currentEpochMillis() + } + +/** Reads the platform's wall clock in milliseconds since the Unix epoch. */ +internal expect fun currentEpochMillis(): Long diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt new file mode 100644 index 000000000..f24cc4c41 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Bookkeeper.kt @@ -0,0 +1,119 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace + +/** + * Tracks successful metadata, consecutive failures, and durable staleness for canonical keys. + * + * Every per-key operation derives identity exclusively from + * `(key.namespace.value, key.canonicalId())`; object identity and concrete key class are never part + * of bookkeeping identity. Every namespace operation similarly normalizes identity exclusively by + * `namespace.value`; [StoreNamespace] object identity is never part of namespace matching. + * Implementations use one store-local monotone sequence shared by every success, per-key stale + * mark, namespace watermark, and global watermark. A key is durably stale exactly when + * `max(mark/ns/global) > (success ?: 0)`. Therefore a failure-only record is not durably stale until + * covered by a positive mark or watermark, and a later success clears earlier staleness. + * + * [recordSuccess], [recordFailure], and operational per-key [forget] are operationally infallible: + * implementations absorb or report their own storage failures and do not throw them through this + * interface. Cooperative cancellation may still propagate. [recordSuccess] clears the prior + * failure timestamp and count. + * + * The maintenance methods [markStale], [advanceStaleWatermark], + * [advanceGlobalStaleWatermark], [forgetNamespace], and [forgetAll] may report storage failures by + * throwing. Each is exception-atomic for every [Throwable], including cancellation: normal return + * means the full operation was applied, while throwing means it had no effect. Forget operations + * remove key records but never reset namespace or global watermarks, and watermarks otherwise only + * advance. + */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface Bookkeeper { + /** + * Records successful metadata for [key], assigns the next shared store-local monotone sequence, + * and clears its failure timestamp and count. + */ + public suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) + + /** + * Records one consecutive failure for [key] at [atEpochMillis] without making a failure-only + * record durably stale. + */ + public suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) + + /** + * Returns canonical status for [key], including watermark-only staleness, or null when neither a + * record nor a covering watermark exists. + */ + public suspend fun status(key: StoreKey): KeyStatus? + + /** + * Forgets [key]'s record, including its per-key stale mark, without resetting namespace or + * global watermarks. + */ + public suspend fun forget(key: StoreKey) + + /** + * Assigns the next shared sequence to [key]'s stale mark as one exception-atomic, fallible + * maintenance operation; the shared sequence never resets. + */ + public suspend fun markStale(key: StoreKey) + + /** + * Assigns the next shared sequence to durable stale coverage for every key in [namespace] as + * one exception-atomic, fallible maintenance operation; the watermark never resets. + */ + public suspend fun advanceStaleWatermark(namespace: StoreNamespace) + + /** + * Assigns the next shared sequence to global stale coverage as one exception-atomic, fallible + * maintenance operation; the watermark never resets. + */ + public suspend fun advanceGlobalStaleWatermark() + + /** + * Forgets key records in [namespace] without resetting watermarks as one exception-atomic, + * fallible maintenance operation. + */ + public suspend fun forgetNamespace(namespace: StoreNamespace) + + /** + * Forgets all key records without resetting watermarks as one exception-atomic, fallible + * maintenance operation. + */ + public suspend fun forgetAll() +} + +/** + * Immutable bookkeeping state for one canonical key. + * + * `durablyStale` reflects the exact watermark algebra `max(mark/ns/global) > (success ?: 0)`. + * A failure-only record therefore reports false until a mark or watermark covers it. + */ +@ExperimentalStoreApi +public class KeyStatus( + /** Metadata from the latest recorded success, or null when none has been recorded. */ + public val meta: StoreMeta?, + + /** The shared monotone sequence assigned to the latest success, or null when none exists. */ + public val lastSuccessSequence: Long?, + + /** The time of the latest [Bookkeeper.recordFailure], null once a success clears it. */ + public val lastFailureAtEpochMillis: Long?, + + /** Failures recorded since the latest success; zero once a success clears the streak. */ + public val consecutiveFailures: Int, + + /** Whether a key, namespace, or global stale sequence outranks this key's latest success. */ + public val durablyStale: Boolean, +) diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Fetcher.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Fetcher.kt new file mode 100644 index 000000000..c04e9191b --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Fetcher.kt @@ -0,0 +1,28 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Retrieves remote values for a [StoreKey] without mutating Store state. + * + * Implementations must be cooperative with coroutine cancellation and must not write Store + * residence, persistence, or bookkeeping directly. The engine supplies a non-null `etag` if and + * only if it selected [FetchPlan.Conditional]; return [FetcherResult.NotModified] to confirm that + * the resident value is still current. + * + * @param K the key type accepted by the fetcher + * @param V the non-null value type produced by the fetcher + */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface Fetcher { + /** Retrieves [key], conditionally against [etag] when one is supplied by the engine. */ + // docs:snippet:guides-fetchers-seam-signature + public suspend fun fetch( + key: K, + etag: String?, + ): FetcherResult + // docs:snippet:end +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FetcherResult.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FetcherResult.kt new file mode 100644 index 000000000..6a3fbd915 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FetcherResult.kt @@ -0,0 +1,45 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.StoreBuilder +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreResult + +/** + * The result vocabulary for a fetcher registered with [StoreBuilder.fetcherOfResult] or the seam + * [Fetcher] overload. + * + * A plain [StoreBuilder.fetcher] is success-or-throw sugar: a returned value becomes [Success], + * while a thrown exception follows the store's fetch-failure path. [NotModified] refreshes the + * resident value's metadata and emits [StoreResult.Revalidated]; without a resident value it + * produces [StoreError.Missing]. A seam [Fetcher] receives the ETag selected by a conditional + * plan; the lambda sugar ignores conditional ETags because its signatures do not accept them. + * [Error] is equivalent to throwing [Error.cause] from the fetcher. [Deleted] destructively clears + * the resident value and forgets its freshness; streams and waiters receive [StoreError.Missing], + * and the deletion does not trigger an automatic refetch. + * + * @param V the non-null value type produced by the fetcher + */ +public sealed interface FetcherResult { + /** A fetched `value`, optionally identified by `etag`. */ + public class Success( + public val value: V, + public val etag: String? = null, + ) : FetcherResult + + /** + * The resident value is unchanged and its metadata should be refreshed with `etag`. + * + * @property etag the refreshed ETag, or null to keep the previously recorded tag + */ + public class NotModified( + public val etag: String? = null, + ) : FetcherResult + + /** A fetch failure equivalent to throwing `cause` from the fetcher. */ + public class Error( + public val cause: Throwable, + ) : FetcherResult + + /** A destructive remote deletion that clears the resident value without auto-refetching. */ + public data object Deleted : FetcherResult +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt new file mode 100644 index 000000000..f3672aaf3 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/FreshnessValidator.kt @@ -0,0 +1,63 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreMeta + +/** + * The value and bookkeeping facts used to plan a read. + * + * `status` carries the durable bookkeeping posture captured before the corresponding engine-state + * snapshot. A resident value with null `meta` is treated as conservatively stale. + */ +@ExperimentalStoreApi +public class FreshnessContext( + /** Whether an in-memory resident value existed in the snapshot this plan is made from. */ + public val hasResidentValue: Boolean, + + /** The resident value's recorded freshness metadata, or null when it has none or is absent. */ + public val meta: StoreMeta?, + + /** Whether the resident value was committed before the stale epoch this plan runs against. */ + public val epochStale: Boolean, + + /** The freshness policy of the read being planned. */ + public val freshness: Freshness, + + /** The wall-clock reading captured for this plan, in Unix epoch milliseconds. */ + public val nowEpochMillis: Long, + + /** The durable bookkeeping posture captured before the corresponding engine-state snapshot. */ + public val status: KeyStatus? = null, +) + +/** Selects the fetch plan for one coherent [FreshnessContext]. */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface FreshnessValidator { + /** Plans whether and how the current read should fetch as a pure function of [context]. */ + public fun plan(context: FreshnessContext): FetchPlan +} + +/** Fetch action selected by a [FreshnessValidator]. */ +@ExperimentalStoreApi +public sealed interface FetchPlan { + /** + * Skips fetching. Skip with no resident value yields [StoreError.Missing] (get throws, stream + * emits Error). + */ + public data object Skip : FetchPlan + + /** Performs an unconditional fetch and optionally serves the resident value while it runs. */ + public class Fetch( + public val servesResidentWhileFetching: Boolean, + ) : FetchPlan + + /** Performs a conditional fetch for `etag` and optionally serves residence while it runs. */ + public class Conditional( + public val etag: String, + public val servesResidentWhileFetching: Boolean, + ) : FetchPlan +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/KeyEvents.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/KeyEvents.kt new file mode 100644 index 000000000..fb901843f --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/KeyEvents.kt @@ -0,0 +1,46 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Advisory notifications produced at Store engine writer points. + * + * This hierarchy is deliberately open rather than sealed, so consumers must retain an `else` + * branch and future minor-version variants remain source- and binary-compatible. Constructors are + * internal because only the engine produces events. + * + * Delivery is a best-effort hot stream with replay `0` and a bounded buffer of `64` that drops the + * oldest event on overflow. Correctness must never depend on observing every event; durable facts + * remain in engine state and bookkeeping. The flow never completes, including after `Store.close`; + * collectors must scope collection to their own lifecycle. + * + * [Written] is produced after a fetch commit with [Origin.FETCHER] and after write-handle apply with + * [Origin.SOT]. [Invalidated] is produced for per-key invalidation and for each swept resident in + * namespace or global invalidation. [Deleted] is produced for per-key clear, a committed server + * deletion, and once per engine in the authoritative first sweep of namespace or global clear. + * Purge sweeps, nonresident watermark coverage, external source-of-truth changes, and superseded + * fetches produce no event. + */ +@ExperimentalStoreApi +public abstract class KeyEvents internal constructor() { + /** Key whose engine produced this advisory event. */ + public abstract val key: StoreKey + + /** Reports a successful writer commit with its installed `origin`. */ + public class Written internal constructor( + override val key: StoreKey, + public val origin: Origin, + ) : KeyEvents() + + /** Reports a successful stale mark for `key`. */ + public class Invalidated internal constructor( + override val key: StoreKey, + ) : KeyEvents() + + /** Reports a successful destructive removal for `key`. */ + public class Deleted internal constructor( + override val key: StoreKey, + ) : KeyEvents() +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Overlay.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Overlay.kt new file mode 100644 index 000000000..a1cdc2f12 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/Overlay.kt @@ -0,0 +1,52 @@ +package org.mobilenativefoundation.store6.core.seam + +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Projects confirmed stream residence through one engine-owned writer per key. + * + * [apply] receives the latest confirmed value, or `null` for confirmed absence. Returning that + * same value by equality preserves its envelope, origin, age, staleness, and refresh state. + * Returning a different non-null value emits it from the overlay with zero age and without + * staleness; its `refreshing` flag always reflects the live fetch slot. Returning `null` exposes + * the normal absent/loading transition. Consequently a + * non-null result over a null base is an optimistic create, while a null result over a non-null + * base is an optimistic delete. `Store.get` is intentionally not projected. + * + * | Confirmed base | [apply] result | Stream projection | + * |---|---|---| + * | non-null | equal value | pass through the confirmed envelope and its metadata | + * | non-null | different non-null value | overlay data | + * | non-null | `null` | absence (optimistic delete) | + * | `null` | non-null value | overlay data (optimistic create) | + * | `null` | `null` | confirmed absence | + * + * The engine invokes [apply] exactly once for each residence revision or matching [changes] + * emission actually accepted by the key's single writer, independent of collector count. [apply] + * must be pure, non-blocking, and no-throw, and must not call back into the Store. It runs outside + * Store locks. [changes] is filtered by canonical key identity; it may complete normally, but it + * must not fail. The mutations extension is the intended producer of change signals after its + * confirmed-commit-then-retire ordering. + * + * A defensive violation by [apply] or [changes], including a self-originated cancellation while + * the engine remains active, terminalizes projection for that key. Every current or future + * projected stream then fails with a deterministic internal exception retaining the cause; the + * engine never silently falls back to an unprojected value. + */ +// docs:snippet:guides-extending-overlay-seam +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface Overlay { + /** Computes the current projected value for [key] from confirmed [base] or absence. */ + public fun apply( + key: K, + base: V?, + ): V? + + /** Signals keys whose projection inputs changed without changing confirmed residence. */ + public val changes: Flow +} +// docs:snippet:end diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/SourceOfTruth.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/SourceOfTruth.kt new file mode 100644 index 000000000..0c34e9bd5 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/SourceOfTruth.kt @@ -0,0 +1,76 @@ +package org.mobilenativefoundation.store6.core.seam + +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace + +/** + * Persistence seam for the current nullable row associated with a [StoreKey]. + * + * Implementations must uphold all of the following reader-liveness and mutation semantics: + * + * - Every collection of [reader] immediately first emits the current row, or `null` when absent. + * - An active collection emits every subsequent change made through this instance, including a + * write equal to the current value and any matching [delete], [deleteNamespace], or [deleteAll] + * as `null`. Emissions may be conflated. + * - A [reader] collection never completes normally. A non-cancellation collection failure is + * permitted; the engine retries it, and each new attempt again starts with the current row. + * Collection cancellation propagates. + * - On normal return, [write], [delete], [deleteNamespace], and [deleteAll] provide + * read-your-writes: a subsequent [reader] collection starts with the applied row or absence, and + * each mutation's current-row notification has been published to every matching active + * collection. Those notifications may still be queued in downstream operators. A mutation may + * publish intermediate rows (including `null`), but the notification of each applied row or + * absence must be that mutation's final notification for that row before normal return. + * Therefore a notification that supersedes a successfully returned mutation is ordered after + * the return boundary. + * - Mutation completion is exception-atomic for every [Throwable], including + * `CancellationException`: normal return means the mutation was applied, while throwing means + * it was not applied. + * + * Reactivity to changes made through another source-of-truth instance is implementation-specific. + * External changes made while no [reader] is collected must appear in the next collection's first + * emission; the engine's memory fast path may serve the previously observed value until that + * collection begins. + * + * `clearCache` is deliberately absent because cache clearing is not a persistence mutation. + * + * @param K the key type used to locate a row + * @param V the non-null row type + */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface SourceOfTruth { + /** Returns the live row stream for [key] under the contract documented on this interface. */ + public fun reader(key: K): Flow + + /** Persists [value] for [key] under the mutation contract documented on this interface. */ + public suspend fun write( + key: K, + value: V, + ) + + /** Destructively removes the row for [key] under the documented mutation contract. */ + public suspend fun delete(key: K) + + /** + * Destructively removes every existing row in [namespace]. + * + * Matching active [reader] collections receive `null` and remain live for later writes. On + * normal return, all matching rows have been removed and their `null` notifications published. + * Cancellation and all other failures preserve exception atomicity under the interface + * mutation contract. + */ + public suspend fun deleteNamespace(namespace: StoreNamespace) + + /** + * Destructively removes every existing row in every namespace. + * + * All active [reader] collections receive `null` and remain live for later writes. On normal + * return, all rows have been removed and their `null` notifications published. Cancellation + * and all other failures preserve exception atomicity under the interface mutation contract. + */ + public suspend fun deleteAll() +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt new file mode 100644 index 000000000..2709e1508 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreResults.kt @@ -0,0 +1,28 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreResult +import kotlin.time.Duration + +/** + * StoreResults is the sanctioned construction door for extensions, fakes, and tests; internal constructors remain internal. + */ +@ExperimentalStoreApi +public object StoreResults { + public fun loading(): StoreResult.Loading = StoreResult.Loading() + public fun data(value: V, origin: Origin, age: Duration, isStale: Boolean, refreshing: Boolean): StoreResult.Data = StoreResult.Data(value, origin, age, isStale, refreshing) + public fun revalidated(age: Duration): StoreResult.Revalidated = StoreResult.Revalidated(age) + public fun error(error: StoreError, servedStale: Boolean): StoreResult.Error = StoreResult.Error(error, servedStale) + public fun exception(error: StoreError, cause: Throwable? = null): StoreException = StoreException(error, cause) + public fun fetchError(message: String, cause: Throwable? = null): StoreError.Fetch = StoreError.Fetch(message, cause) + public fun persistenceError(message: String, cause: Throwable? = null): StoreError.Persistence = StoreError.Persistence(message, cause) + public fun conversionError(message: String, cause: Throwable? = null): StoreError.Conversion = StoreError.Conversion(message, cause) + public fun freshnessUnsatisfiable(message: String): StoreError.FreshnessUnsatisfiable = StoreError.FreshnessUnsatisfiable(message) + public fun conflict(serverMeta: StoreMeta?, message: String): StoreError.Conflict = StoreError.Conflict(serverMeta, message) + public fun missing(key: StoreKey, message: String): StoreError.Missing = StoreError.Missing(key, message) +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreRuntime.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreRuntime.kt new file mode 100644 index 000000000..250d5bc0a --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreRuntime.kt @@ -0,0 +1,39 @@ +package org.mobilenativefoundation.store6.core.seam + +import kotlinx.coroutines.flow.Flow +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.internal.RealStore + +/** + * Optional engine-backed capabilities exposed to Store extensions without implementation downcasts. + * + * @param K the key type accepted by the owning Store + * @param V the non-null value type produced by the owning Store + */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface StoreRuntime { + /** Engine-backed acknowledgement and freshness capability. */ + public val writeHandle: StoreWriteHandle + + /** Best-effort advisory engine events; this hot flow never completes, even after Store close. */ + public val keyEvents: Flow + + /** Exact telemetry sink configured at build time, or `null` when telemetry is unset. */ + public val telemetry: StoreTelemetry? +} + +/** + * Returns this Store's engine-backed capability handle. + * + * Non-engine stores, fakes, and decorators return `null`; a decorator exposes its own affordances. + * The single unchecked cast is contained inside the library. + */ +@ExperimentalStoreApi +public fun Store.runtime(): StoreRuntime? { + @Suppress("UNCHECKED_CAST") + return (this as? RealStore)?.runtime +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreTelemetry.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreTelemetry.kt new file mode 100644 index 000000000..c1deee2f9 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreTelemetry.kt @@ -0,0 +1,55 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreKey +import kotlin.time.Duration + +/** + * Observes Store lifecycle events without participating in Store correctness. + * + * Handlers are non-suspending and must be non-blocking and must not throw. The engine never invokes + * them while its state or write lock is held. [onServe] runs for every public data emission and + * successful `get` return. For a revalidation it runs only when the rendered projection retains a + * visible value: pass-through uses the effective authorized origin, an overlaid value uses + * `Origin.OVERLAY`, and projected absence has no successful serve and therefore no hook. + * + * When telemetry is unset the engine retains a null reference, every call site short-circuits with + * a null guard, and no fetch-duration mark is allocated. When configured, [onFetchStarted] runs at + * fetch-coroutine start. [onFetchSucceeded] or [onFetchFailed] runs after commit or settlement and + * before the fetch ticket completes, so every resumed waiter observes the terminal hook first. + * Superseded fetches have no terminal hook. + */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface StoreTelemetry { + /** Observes the start of a fetch attempt for [key]. */ + public fun onFetchStarted(key: StoreKey) {} + + /** Observes successful fetch commit or revalidation for [key]. */ + public fun onFetchSucceeded( + key: StoreKey, + duration: Duration, + ) {} + + /** Observes terminal fetch [error] for [key]. */ + public fun onFetchFailed( + key: StoreKey, + error: StoreError, + duration: Duration, + ) {} + + /** Observes a successful public serve of [key] from [origin]. */ + public fun onServe( + key: StoreKey, + origin: Origin, + ) {} + + /** Observes successful invalidation of [key]. */ + public fun onInvalidated(key: StoreKey) {} + + /** Observes successful clearing of [key]. */ + public fun onCleared(key: StoreKey) {} +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreWriteHandle.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreWriteHandle.kt new file mode 100644 index 000000000..ba0955286 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/StoreWriteHandle.kt @@ -0,0 +1,55 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Engine-backed acknowledgement path for committing and refreshing source-of-truth values. + * + * @param K the key type accepted by the owning Store + * @param V the non-null value type committed by the owning Store + */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface StoreWriteHandle { + /** + * Commits [value] to the source of truth for [key] under the engine write lock. + * + * This acknowledgement path publishes synthetic-ticket attribution from committing through + * committed so streams observe `Data(origin = SOT)`. It never fetches or calls the network and + * does not record bookkeeping success; callers pair it with [confirmFresh] when the value is + * known fresh. + * + * Cancellation propagates after the synthetic owner state is terminalized. Other persistence + * failures throw [StoreException] carrying [StoreError.Persistence], retain the original cause, + * and leave engine state safe and unchanged. + */ + public suspend fun apply( + key: K, + value: V, + ) + + /** + * Marks [key] durably stale with semantics identical to `Store.invalidate(key)`. + * + * Active streams are signaled to refetch, and the engine produces both + * [KeyEvents.Invalidated] and the configured invalidation telemetry callback. + */ + public suspend fun markStale(key: K) + + /** + * Confirms the resident value for [key] as fresh without fetching. + * + * When residence exists, this records bookkeeping success, clears durable staleness like a + * `304 Not Modified`, and refreshes resident metadata and its commit epoch. Active streams may + * observe one data re-emission with refreshed flags. With no resident value this does nothing. + * This call alone is not an observation mechanism. + */ + public suspend fun confirmFresh( + key: K, + etag: String?, + ) +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt new file mode 100644 index 000000000..369d7e14e --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/TransactionalSourceOfTruth.kt @@ -0,0 +1,22 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey + +/** + * Optional atomicity capability for a [SourceOfTruth]. Detectable via + * `sot is TransactionalSourceOfTruth`; the engine never assumes it and there is deliberately no + * silent non-atomic default. + * + * @param K the key type used to locate a row + * @param V the non-null row type + */ +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface TransactionalSourceOfTruth : SourceOfTruth { + /** Runs [block] atomically with respect to writes made through this source. */ + // docs:snippet:guides-persistence-transaction-signature + public suspend fun withTransaction(block: suspend () -> R): R + // docs:snippet:end +} diff --git a/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/WallClock.kt b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/WallClock.kt new file mode 100644 index 000000000..1ce8dc915 --- /dev/null +++ b/store6-core/src/commonMain/kotlin/org/mobilenativefoundation/store6/core/seam/WallClock.kt @@ -0,0 +1,20 @@ +package org.mobilenativefoundation.store6.core.seam + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi + +/** + * Supplies wall-clock time only for age and bounds calculations. Implementations must be cheap and + * non-blocking. + * + * The store-global monotone success sequence handles ordering; implementations must not use wall + * time as an ordering substitute. + */ +// docs:snippet:guides-extending-wall-clock-seam +@ExperimentalStoreApi +@SubclassOptInRequired(DelicateStoreApi::class) +public interface WallClock { + /** Returns the current wall-clock time in milliseconds since the Unix epoch. */ + public fun nowEpochMillis(): Long +} +// docs:snippet:end diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/EmissionSequenceConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/EmissionSequenceConformanceTest.kt new file mode 100644 index 000000000..6d27bdb1e --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/EmissionSequenceConformanceTest.kt @@ -0,0 +1,333 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.turbineScope +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +open class EmissionSequenceConformanceTest : SourceOfTruthSubstitutionTest() { + @Test + fun ac1a_staleWhileRevalidate_successEmitsStaleThenExactlyOneFreshData() = runTest { + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + val key = TestKey("1") + turbineScope { + // A retained LocalOnly observer makes the empty-reader boundary public and + // byte-identical across every substituted SourceOfTruth. + val localCollector = + store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + val missing = assertIs(localCollector.awaitItem()) + assertIs(missing.error) + assertFalse(missing.servedStale) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + val initialCollector = store.stream(key).testIn(backgroundScope) + assertIs(initialCollector.awaitItem()) + val initial = assertIs>(initialCollector.awaitItem()) + assertEquals("v1", initial.value) + assertFalse(initial.isStale) + assertFalse(initial.refreshing) + assertEquals(1, calls) + val localInitial = assertIs>(localCollector.awaitItem()) + assertEquals("v1", localInitial.value) + assertFalse(localInitial.isStale) + assertFalse(localInitial.refreshing) + + store.invalidate(key) + // Prove the retained seed collector has processed invalidation and registered + // the gated refetch before the target collector joins it. Otherwise a delayed + // seed watcher can first run in the slot-settle/write-through I3 window. + secondStarted.awaitFromDefault() + val collector = store.stream(key).testIn(backgroundScope) + val stale = assertIs>(collector.awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + secondGate.complete(Unit) + + var fresh = assertIs>(collector.awaitItem()) + var queuedStaleReplays = 0 + while (fresh.value == "v1") { + // At-least-latest Data permits a queued stale replay to reach a fast + // collector; it may not replace or follow the one clean terminal value. + queuedStaleReplays += 1 + assertTrue( + queuedStaleReplays <= QUEUED_STALE_REPLAY_BOUND, + "queued stale replays exceeded the ratified bound", + ) + assertEquals("v1", fresh.value) + assertTrue(fresh.isStale) + assertTrue(fresh.refreshing) + fresh = assertIs>(collector.awaitItem()) + } + assertEquals("v2", fresh.value) + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + collector.expectNoEvents() + assertEquals(2, calls) + localCollector.cancelAndIgnoreRemainingEvents() + initialCollector.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun ac1b_staleWhileRevalidate_failureEmitsStaleThenExactlyOneServedStaleError() = runTest { + var calls = 0 + val boom = IllegalStateException("boom") + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + throw boom + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + store.invalidate(TestKey("1")) + + store.stream(TestKey("1")).test { + val stale = assertIs>(awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + secondStarted.awaitFromDefault() + secondGate.complete(Unit) + + var terminal = awaitItem() + var queuedStaleReplays = 0 + while (terminal is StoreResult.Data<*>) { + queuedStaleReplays += 1 + assertTrue( + queuedStaleReplays <= QUEUED_STALE_REPLAY_BOUND, + "queued stale replays exceeded the ratified bound", + ) + assertEquals("v1", terminal.value) + assertTrue(terminal.isStale) + assertTrue(terminal.refreshing) + terminal = awaitItem() + } + val failure = assertIs(terminal) + val fetch = assertIs(failure.error) + assertTrue(fetch.cause === boom) + assertTrue(failure.servedStale) + expectNoEvents() + assertEquals(2, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun ac1c_invalidationRacingInitialFailureEmitsOneErrorThenSecondCycleData() = runTest { + var calls = 0 + val boom = IllegalStateException("boom") + val firstStarted = CompletableDeferred() + val firstGate = CompletableDeferred() + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val store = testStore { + fetcherOfResult { + when (++calls) { + 1 -> { + firstStarted.complete(Unit) + firstGate.await() + FetcherResult.Error(boom) + } + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + FetcherResult.Success("v2") + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + firstStarted.awaitFromDefault() + + store.invalidate(TestKey("1")) + firstGate.complete(Unit) + + val failure = assertIs(awaitItem()) + val fetch = assertIs(failure.error) + assertTrue(fetch.cause === boom) + assertFalse(failure.servedStale) + + secondStarted.awaitFromDefault() + expectNoEvents() + secondGate.complete(Unit) + + val fresh = assertIs>(awaitItem()) + assertEquals("v2", fresh.value) + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + expectNoEvents() + assertEquals(2, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + firstGate.complete(Unit) + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun ac1d_notModifiedEmitsExactlyOneRevalidatedWithoutFreshData() = runTest { + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val key = TestKey("1") + val store = testStore { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + FetcherResult.NotModified(etag = "e1") + } + // A cold-baseline 304 commits ObsoleteRevalidation and legally self-heals + // with exactly one replanned conditional fetch. + 3 -> FetcherResult.NotModified(etag = "e1") + else -> error("unexpected fetch call $calls") + } + } + } + + try { + turbineScope { + // A retained LocalOnly observer makes the empty-reader boundary public and + // byte-identical across every substituted SourceOfTruth. + val localCollector = + store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + val missing = assertIs(localCollector.awaitItem()) + assertIs(missing.error) + assertFalse(missing.servedStale) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + val initialCollector = store.stream(key).testIn(backgroundScope) + assertIs(initialCollector.awaitItem()) + val initial = assertIs>(initialCollector.awaitItem()) + assertEquals("v1", initial.value) + assertFalse(initial.isStale) + assertFalse(initial.refreshing) + assertEquals(1, calls) + val localInitial = assertIs>(localCollector.awaitItem()) + assertEquals("v1", localInitial.value) + assertFalse(localInitial.isStale) + assertFalse(localInitial.refreshing) + + store.invalidate(key) + secondStarted.awaitFromDefault() + val collector = store.stream(key).testIn(backgroundScope) + val stale = assertIs>(collector.awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + secondGate.complete(Unit) + + var terminal = collector.awaitItem() + var queuedStaleReplays = 0 + while (terminal is StoreResult.Data<*>) { + // A queued pre-304 replay may survive as Data, but it must remain the stale + // baseline; the owner-visible fresh terminal is exclusively Revalidated. + queuedStaleReplays += 1 + assertTrue( + queuedStaleReplays <= QUEUED_STALE_REPLAY_BOUND, + "queued stale replays exceeded the ratified bound", + ) + assertEquals("v1", terminal.value) + assertTrue(terminal.isStale) + assertTrue(terminal.refreshing) + terminal = collector.awaitItem() + } + assertIs(terminal) + collector.expectNoEvents() + assertTrue( + calls in 2..3, + "the 304 cycle may self-heal one obsolete cold-baseline launch", + ) + localCollector.cancelAndIgnoreRemainingEvents() + initialCollector.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } +} + +private const val QUEUED_STALE_REPLAY_BOUND = 1 + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } + +// Preserve Default-dispatch ordering and let the suite-level runTest bound own cancellation. +private suspend fun CompletableDeferred.awaitFromDefault(): T = + withContext(Dispatchers.Default) { + await() + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FetcherContractTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FetcherContractTest.kt new file mode 100644 index 000000000..abfa34dfe --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FetcherContractTest.kt @@ -0,0 +1,268 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalStoreApi::class) +class FetcherContractTest { + @Test + fun fetcherResultError_cancellationIsEquivalentToThrowingCancellation() = runTest { + val returnedCancellation = CancellationException("fetch cancelled") + val richStore = store { + fetcherOfResult { FetcherResult.Error(returnedCancellation) } + } + val thrownCancellation = CancellationException("fetch cancelled") + val plainStore = store { + fetcher { throw thrownCancellation } + } + + try { + val returnedFailure = + assertFailsWith { + richStore.get(TestKey("1")) + } + assertEquals("fetch cancelled", returnedFailure.message) + assertTrue( + returnedFailure === returnedCancellation || + returnedFailure.cause === returnedCancellation, + ) + + val thrownFailure = + assertFailsWith { + plainStore.get(TestKey("1")) + } + assertEquals("fetch cancelled", thrownFailure.message) + assertTrue( + thrownFailure === thrownCancellation || thrownFailure.cause === thrownCancellation, + ) + } finally { + richStore.close() + plainStore.close() + } + } + + @Test + fun richSuccess_streamEmitsLoadingThenFetcherData() = runTest { + val store = store { + fetcherOfResult { FetcherResult.Success("v", etag = "e1") } + } + + try { + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + val data = assertIs>(awaitItem()) + assertEquals("v", data.value) + assertEquals(Origin.FETCHER, data.origin) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun richError_matchesThrownFetcherFailureForGetAndStream() = runTest { + val boom = IllegalStateException("boom") + val store = store { + fetcherOfResult { FetcherResult.Error(boom) } + } + + try { + val getFailure = assertFailsWith { store.get(TestKey("1")) } + val getFetch = assertIs(getFailure.error) + assertTrue(getFetch.cause === boom) + + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + val failure = assertIs(awaitItem()) + val streamFetch = assertIs(failure.error) + assertTrue(streamFetch.cause === boom) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun deletedAfterResident_emitsOneLoadingAndOneMissingWithoutLoop() = runTest { + var calls = 0 + val store = store { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2, 3 -> FetcherResult.Deleted + else -> error("unexpected fetch call $calls") + } + } + } + val key = TestKey("1") + + try { + assertEquals("v1", store.get(key)) + + store.stream(key).test { + assertEquals("v1", assertIs>(awaitItem()).value) + store.invalidate(key) + + val events = listOf(awaitItem(), awaitItem()) + assertEquals(1, events.count { it is StoreResult.Loading }) + val failures = events.filterIsInstance() + assertEquals(1, failures.size) + val missing = assertIs(failures.single().error) + assertTrue(missing.message.lowercase().contains("deleted")) + expectNoEvents() + + val laterFailure = assertFailsWith { store.get(key) } + val laterMissing = assertIs(laterFailure.error) + assertTrue(laterMissing.message.lowercase().contains("deleted")) + assertEquals(3, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun deletedOnFirstGet_reportsMissing() = runTest { + val store = store { + fetcherOfResult { FetcherResult.Deleted } + } + + try { + val failure = assertFailsWith { store.get(TestKey("1")) } + val missing = assertIs(failure.error) + assertTrue(missing.message.lowercase().contains("deleted")) + } finally { + store.close() + } + } + + @Test + fun deletedOnFirstStream_emitsLoadingThenMissingAndStaysLive() = runTest { + val store = store { + fetcherOfResult { FetcherResult.Deleted } + } + + try { + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + val failure = assertIs(awaitItem()) + val missing = assertIs(failure.error) + assertTrue(missing.message.lowercase().contains("deleted")) + assertEquals(false, failure.servedStale) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun notModifiedWithoutResident_reportsMissingForGetAndStream() = runTest { + val store = store { + fetcherOfResult { FetcherResult.NotModified(etag = "e1") } + } + + try { + val getFailure = assertFailsWith { store.get(TestKey("1")) } + val getMissing = assertIs(getFailure.error) + assertTrue(getMissing.message.contains("NotModified")) + + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + val failure = assertIs(awaitItem()) + val streamMissing = assertIs(failure.error) + assertTrue(streamMissing.message.contains("NotModified")) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun successNotModifiedAndDeleted_updateAndForgetBookkeeper() = runTest { + var calls = 0 + val bookkeeper = InMemoryBookkeeper() + val clock = FakeWallClock(now = 1_000L) + val store = storeWith(clock = clock, bookkeeper = bookkeeper) { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + 3 -> FetcherResult.Deleted + else -> error("unexpected fetch call $calls") + } + } + } + val key = TestKey("1") + + try { + assertEquals("v1", store.get(key)) + val seeded = assertNotNull(bookkeeper.status(key)) + assertEquals("e1", seeded.meta?.etag) + assertEquals(1L, seeded.lastSuccessSequence) + + clock.now = 2_000L + assertEquals("v1", store.get(key, Freshness.MustBeFresh)) + val revalidated = assertNotNull(bookkeeper.status(key)) + assertEquals("e2", revalidated.meta?.etag) + assertEquals(2L, revalidated.lastSuccessSequence) + assertTrue( + assertNotNull(revalidated.lastSuccessSequence) > + assertNotNull(seeded.lastSuccessSequence), + ) + + val deleted = + assertFailsWith { + store.get(key, Freshness.MustBeFresh) + } + val missing = assertIs(deleted.error) + assertTrue(missing.message.lowercase().contains("deleted")) + assertNull(bookkeeper.status(key)) + assertEquals(3, calls) + } finally { + store.close() + } + } + + @Test + fun notModifiedWithNullEtag_retainsPreviousEtag() = runTest { + var calls = 0 + val bookkeeper = InMemoryBookkeeper() + val store = storeWith(bookkeeper = bookkeeper) { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = null) + else -> error("unexpected fetch call $calls") + } + } + } + val key = TestKey("1") + + try { + assertEquals("v1", store.get(key)) + assertEquals("v1", store.get(key, Freshness.MustBeFresh)) + assertEquals("e1", bookkeeper.status(key)?.meta?.etag) + } finally { + store.close() + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FreshnessPolicyConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FreshnessPolicyConformanceTest.kt new file mode 100644 index 000000000..68a67dbfc --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/FreshnessPolicyConformanceTest.kt @@ -0,0 +1,504 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.turbineScope +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class) +open class FreshnessPolicyConformanceTest : SourceOfTruthSubstitutionTest() { + @Test + fun maxAgeWithinBoundServesResidentWithoutSecondFetch() = runTest { + val clock = FakeWallClock(now = 0L) + var calls = 0 + val store = testStoreWith(clock = clock) { + fetcher { "v${++calls}" } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + clock.now = 60.seconds.inWholeMilliseconds + + assertEquals( + "v1", + store.get(TestKey("1"), Freshness.MaxAge(notOlderThan = 5.minutes)), + ) + assertEquals(1, calls) + } finally { + store.closeAndSettleForTest() + } + } + + @Test + fun maxAgeOverBoundGetBlocksForAndReturnsFreshValue() = runTest { + val clock = FakeWallClock(now = 0L) + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val store = testStoreWith(clock = clock) { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + clock.now = 600.seconds.inWholeMilliseconds + + val fresh = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(TestKey("1"), Freshness.MaxAge(notOlderThan = 5.minutes)) + } + assertFalse(fresh.isCompleted) + secondStarted.awaitFromDefault() + secondGate.complete(Unit) + + assertEquals("v2", fresh.await()) + assertEquals(2, calls) + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun maxAgeOverBoundStreamWithholdsResidentUntilFreshValue() = runTest { + val clock = FakeWallClock(now = 0L) + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val store = testStoreWith(clock = clock) { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + val key = TestKey("1") + turbineScope { + // A retained LocalOnly observer makes the empty-reader boundary public and + // byte-identical across every substituted SourceOfTruth. + val localCollector = + store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + val missing = assertIs(localCollector.awaitItem()) + assertIs(missing.error) + assertFalse(missing.servedStale) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + val initialCollector = store.stream(key).testIn(backgroundScope) + assertIs(initialCollector.awaitItem()) + val initial = assertIs>(initialCollector.awaitItem()) + assertEquals("v1", initial.value) + assertFalse(initial.isStale, "seed frame must not be stale: $initial") + assertFalse( + initial.refreshing, + "seed frame must not remain refreshing: $initial", + ) + assertEquals(1, calls) + val localInitial = assertIs>(localCollector.awaitItem()) + assertEquals("v1", localInitial.value) + assertFalse(localInitial.isStale) + assertFalse(localInitial.refreshing) + clock.now = 600.seconds.inWholeMilliseconds + + val collector = + store.stream( + key, + Freshness.MaxAge(notOlderThan = 5.minutes), + ).testIn(backgroundScope) + assertIs(collector.awaitItem()) + secondStarted.awaitFromDefault() + secondGate.complete(Unit) + + val fresh = assertIs>(collector.awaitItem()) + assertEquals("v2", fresh.value) + assertEquals(Duration.ZERO, fresh.age) + assertFalse(fresh.isStale, "fresh frame must not be stale: $fresh") + assertFalse( + fresh.refreshing, + "fresh terminal must not remain refreshing: $fresh", + ) + collector.expectNoEvents() + localCollector.cancelAndIgnoreRemainingEvents() + initialCollector.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + assertEquals(2, calls) + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun maxAgeOverBoundFailureDoesNotFallBackForGetOrStream() = runTest { + val clock = FakeWallClock(now = 0L) + val boom = IllegalStateException("boom") + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val thirdStarted = CompletableDeferred() + val thirdGate = CompletableDeferred() + val store = testStoreWith(clock = clock) { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + throw boom + } + 3 -> { + thirdStarted.complete(Unit) + thirdGate.await() + throw boom + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + clock.now = 600.seconds.inWholeMilliseconds + + val get = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + store.get(TestKey("1"), Freshness.MaxAge(notOlderThan = 5.minutes)) + } + } + assertFalse(get.isCompleted) + secondStarted.awaitFromDefault() + secondGate.complete(Unit) + + val getFailure = assertIs(get.await().exceptionOrNull()) + val getFetch = assertIs(getFailure.error) + assertTrue(getFetch.cause === boom) + + store.stream( + TestKey("1"), + Freshness.MaxAge(notOlderThan = 5.minutes), + ).test { + assertIs(awaitItem()) + thirdStarted.awaitFromDefault() + thirdGate.complete(Unit) + + val failure = assertIs(awaitItem()) + val streamFetch = assertIs(failure.error) + assertTrue(streamFetch.cause === boom) + assertFalse(failure.servedStale) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + assertEquals(3, calls) + } finally { + secondGate.complete(Unit) + thirdGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun mustBeFreshRefetchesFreshResident() = runTest { + var calls = 0 + val store = testStore { + fetcher { "v${++calls}" } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + assertEquals("v2", store.get(TestKey("1"), Freshness.MustBeFresh)) + assertEquals(2, calls) + } finally { + store.closeAndSettleForTest() + } + } + + @Test + fun mustBeFreshFailureHasNoFallbackAndStreamCompletes() = runTest { + val boom = IllegalStateException("boom") + var calls = 0 + val store = testStore { + fetcher { + if (++calls == 1) "v1" else throw boom + } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + + val getFailure = + assertFailsWith { + store.get(TestKey("1"), Freshness.MustBeFresh) + } + val getFetch = assertIs(getFailure.error) + assertTrue(getFetch.cause === boom) + + store.stream(TestKey("1"), Freshness.MustBeFresh).test { + assertIs(awaitItem()) + val failure = assertIs(awaitItem()) + val streamFetch = assertIs(failure.error) + assertTrue(streamFetch.cause === boom) + assertFalse(failure.servedStale) + awaitComplete() + } + } finally { + store.closeAndSettleForTest() + } + } + + @Test + fun staleIfErrorAfterInvalidationWaitsForFailureThenReturnsResident() = runTest { + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val boom = IllegalStateException("boom") + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + throw boom + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + store.invalidate(TestKey("1")) + + val fallback = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(TestKey("1"), Freshness.StaleIfError) + } + assertFalse(fallback.isCompleted) + secondStarted.awaitFromDefault() + secondGate.complete(Unit) + + assertEquals("v1", fallback.await()) + assertEquals(2, calls) + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun staleIfErrorWithoutResidentThrowsFetch() = runTest { + val boom = IllegalStateException("boom") + var calls = 0 + val store = testStore { + fetcher { + calls++ + throw boom + } + } + + try { + val failure = + assertFailsWith { + store.get(TestKey("1"), Freshness.StaleIfError) + } + val fetch = assertIs(failure.error) + assertTrue(fetch.cause === boom) + assertEquals(1, calls) + } finally { + store.closeAndSettleForTest() + } + } + + @Test + fun staleIfErrorStreamEmitsStaleThenServedStaleErrorAndStaysLive() = runTest { + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val boom = IllegalStateException("boom") + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + throw boom + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(TestKey("1"))) + store.invalidate(TestKey("1")) + + store.stream(TestKey("1"), Freshness.StaleIfError).test { + val stale = assertIs>(awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + secondStarted.awaitFromDefault() + secondGate.complete(Unit) + + var terminal = awaitItem() + var queuedStaleReplays = 0 + while (terminal is StoreResult.Data<*>) { + queuedStaleReplays += 1 + assertTrue( + queuedStaleReplays <= QUEUED_STALE_REPLAY_BOUND, + "queued stale replays exceeded the ratified bound", + ) + assertEquals("v1", terminal.value) + assertTrue(terminal.isStale) + assertTrue(terminal.refreshing) + terminal = awaitItem() + } + val failure = assertIs(terminal) + val fetch = assertIs(failure.error) + assertTrue(fetch.cause === boom) + assertTrue(failure.servedStale) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + assertEquals(2, calls) + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun localOnlyWithoutResidentReportsMissingWithoutLoadingOrFetcherCall() = runTest { + var calls = 0 + val store = testStore { + fetcher { + calls++ + "remote" + } + } + + try { + val getFailure = + assertFailsWith { + store.get(TestKey("1"), Freshness.LocalOnly) + } + val getMissing = assertIs(getFailure.error) + assertTrue(getMissing.message.contains("LocalOnly")) + + store.stream(TestKey("1"), Freshness.LocalOnly).test { + val failure = assertIs(awaitItem()) + val streamMissing = assertIs(failure.error) + assertTrue(streamMissing.message.contains("LocalOnly")) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + assertEquals(0, calls) + } finally { + store.closeAndSettleForTest() + } + } + + @Test + fun localOnlyResidentIgnoresInvalidationForGetAndStream() = runTest { + var calls = 0 + val store = testStore { + fetcher { "v${++calls}" } + } + val key = TestKey("1") + + try { + assertEquals("v1", store.get(key)) + + store.stream(key, Freshness.LocalOnly).test { + assertEquals("v1", assertIs>(awaitItem()).value) + store.invalidate(key) + + assertEquals("v1", store.get(key, Freshness.LocalOnly)) + expectNoEvents() + assertEquals(1, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.closeAndSettleForTest() + } + } + + @Test + fun dataAgeUsesInjectedWallClock() = runTest { + val clock = FakeWallClock(now = 1_000L) + val store = testStoreWith(clock = clock) { + fetcher { "v" } + } + + try { + assertEquals("v", store.get(TestKey("1"))) + clock.now = 31_000L + + store.stream(TestKey("1")).test { + val data = assertIs>(awaitItem()) + assertEquals(30.seconds, data.age) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.closeAndSettleForTest() + } + } +} + +private const val QUEUED_STALE_REPLAY_BOUND = 1 + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } + +// Preserve Default-dispatch ordering and let the suite-level runTest bound own cancellation. +private suspend fun CompletableDeferred.awaitFromDefault(): T = + withContext(Dispatchers.Default) { + await() + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/KeyEngineSourceOfTruthRaceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/KeyEngineSourceOfTruthRaceTest.kt new file mode 100644 index 000000000..22de08072 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/KeyEngineSourceOfTruthRaceTest.kt @@ -0,0 +1,656 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.internal.DefaultFreshnessValidator +import org.mobilenativefoundation.store6.core.internal.FetchDisposition +import org.mobilenativefoundation.store6.core.internal.FetchOutcome +import org.mobilenativefoundation.store6.core.internal.FetchSlot +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.KeyEngine +import org.mobilenativefoundation.store6.core.internal.KeyId +import org.mobilenativefoundation.store6.core.internal.ResultFetcher +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class KeyEngineSourceOfTruthRaceTest { + @Test + fun nullMetaHydration_servesLocalOnlyAndStartsOneCachedOrFetchRevalidation() = runTest { + val sourceOfTruth = InMemorySourceOfTruth() + val key = TestKey("key") + sourceOfTruth.write(key, "durable") + val fetched = CompletableDeferred() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + fetchCalls += 1 + fetched.complete(Unit) + "fresh" + } + } + + try { + assertEquals("durable", store.get(key, Freshness.LocalOnly)) + assertEquals("durable", store.get(key, Freshness.CachedOrFetch)) + fetched.await() + sourceOfTruth.reader(key).filter { it == "fresh" }.first() + runCurrent() + assertEquals(1, fetchCalls) + assertEquals("fresh", store.get(key, Freshness.LocalOnly)) + } finally { + store.close() + } + } + + @Test + fun readerFactoryFailure_isTypedOnceForAnOutageAndRecovers() = runTest { + val sourceOfTruth = FailingReaderSourceOfTruth() + val key = TestKey("key") + val store = store { + persistence(sourceOfTruth) + fetcher { "seed" } + } + + try { + assertEquals("seed", store.get(key)) + sourceOfTruth.beginFactoryOutage(failures = 3) + + store.stream(key, Freshness.LocalOnly).test { + assertEquals("seed", assertIs>(awaitItem()).value) + val failure = assertIs(awaitItem()) + assertIs(failure.error) + + withContext(Dispatchers.Default) { + sourceOfTruth.recovered.await() + } + assertTrue(sourceOfTruth.readerCalls >= 4) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun readerCollectionFailure_isTypedOnceForAContiguousOutageAndRecovers() = runTest { + val sourceOfTruth = FailingReaderSourceOfTruth() + val key = TestKey("key") + val store = store { + persistence(sourceOfTruth) + fetcher { "seed" } + } + + try { + assertEquals("seed", store.get(key)) + sourceOfTruth.beginCollectionOutage(failures = 3, emitBeforeFirstFailure = true) + + store.stream(key, Freshness.LocalOnly).test { + assertEquals("seed", assertIs>(awaitItem()).value) + val failure = assertIs(awaitItem()) + assertIs(failure.error) + + withContext(Dispatchers.Default) { + sourceOfTruth.recovered.await() + } + assertTrue(sourceOfTruth.readerCalls >= 4) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun twoCollectorsShareOneReaderRetryLoop() = runTest { + val sourceOfTruth = FailingReaderSourceOfTruth() + val key = TestKey("key") + val store = store { + persistence(sourceOfTruth) + fetcher { "seed" } + } + + try { + assertEquals("seed", store.get(key)) + sourceOfTruth.beginFactoryOutage(failures = 1) + + app.cash.turbine.turbineScope { + val first = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + val second = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(first.awaitItem()).value) + assertEquals("seed", assertIs>(second.awaitItem()).value) + assertIs( + assertIs(first.awaitItem()).error, + ) + assertIs( + assertIs(second.awaitItem()).error, + ) + withContext(Dispatchers.Default) { + sourceOfTruth.recovered.await() + } + assertEquals(2, sourceOfTruth.readerCalls) + first.cancelAndIgnoreRemainingEvents() + second.cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun hydrationReaderFailure_isTypedPersistenceForGetAndRecoversOnNextRead() = runTest { + val boom = IllegalStateException("reader failed") + val sourceOfTruth = AdapterFailureSourceOfTruth(readerFailure = boom) + val store = store { + persistence(sourceOfTruth) + fetcher { "unused" } + } + + try { + val failure = + assertFailsWith { + store.get(TestKey("key"), Freshness.LocalOnly) + } + val persistence = assertIs(failure.error) + assertTrue(persistence.cause === boom) + assertTrue(persistence.message.contains("Durable data could not be observed")) + + sourceOfTruth.recoverReaderWith("durable") + assertEquals("durable", store.get(TestKey("key"), Freshness.LocalOnly)) + } finally { + store.close() + } + } + + @Test + fun persistenceWriteFailure_revokesTag_reportsTypedFailure_andLaterRowIsSot() = runTest { + val boom = IllegalStateException("write rejected") + val sourceOfTruth = GatedFailingWriteSourceOfTruth(failure = boom) + val bookkeeper = RecordingBookkeeper() + val clock = FakeWallClock(now = 100L) + val key = TestKey("key") + val engine = + engine( + key = key, + sourceOfTruth = sourceOfTruth, + bookkeeper = bookkeeper, + clock = clock, + ) { FetcherResult.Success("fetched") } + + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + val ticket = assertIs(engine.state.value.fetch).ticket + runCurrent() + sourceOfTruth.writeStarted.await() + assertNotNull(engine.state.value.attribution) + + clock.now = 125L + sourceOfTruth.releaseWrite.complete(Unit) + val failure = assertIs(read.await().exceptionOrNull()) + val persistence = assertIs(failure.error) + assertTrue(persistence.cause === boom) + assertTrue(persistence.message.contains("source of truth rejected the write")) + + assertNull(engine.state.value.attribution) + assertEquals(FetchDisposition.Failed, ticket.disposition.value) + val outcome = assertIs(ticket.outcome.await()) + assertEquals(125L, outcome.atEpochMillis) + assertTrue(outcome.bookkeepingRecorded) + assertIs(outcome.exception.error) + assertEquals( + listOf( + BookkeepingEvent.Failure(KeyId.from(key), atEpochMillis = 125L), + ), + bookkeeper.events, + ) + val status = assertNotNull(bookkeeper.status(key)) + assertEquals(125L, status.lastFailureAtEpochMillis) + assertEquals(1, status.consecutiveFailures) + assertNull(status.meta) + + engine.stream(Freshness.LocalOnly).test { + sourceOfTruth.publishExternal("external") + var external: StoreResult.Data? = null + while (external == null) { + when (val item = awaitItem()) { + is StoreResult.Data -> external = item + is StoreResult.Error -> assertIs(item.error) + is StoreResult.Loading -> Unit + is StoreResult.Revalidated -> error("LocalOnly must not revalidate") + } + } + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + assertTrue(external.isStale) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun persistenceWriteFailure_queuedClear_forgetWinsAndNoFailureResurfaces() = runTest { + val boom = IllegalStateException("write rejected") + val sourceOfTruth = GatedFailingWriteSourceOfTruth(failure = boom) + val bookkeeper = RecordingBookkeeper() + val clock = FakeWallClock(now = 200L) + val key = TestKey("key") + val engine = + engine( + key = key, + sourceOfTruth = sourceOfTruth, + bookkeeper = bookkeeper, + clock = clock, + ) { FetcherResult.Success("fetched") } + + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + val ticket = assertIs(engine.state.value.fetch).ticket + runCurrent() + sourceOfTruth.writeStarted.await() + assertNotNull(engine.state.value.attribution) + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + runCurrent() + assertEquals(0, sourceOfTruth.deleteCalls) + + clock.now = 225L + sourceOfTruth.releaseWrite.complete(Unit) + val failure = assertIs(read.await().exceptionOrNull()) + val persistence = assertIs(failure.error) + assertTrue(persistence.cause === boom) + clear.await() + runCurrent() + + val outcome = assertIs(ticket.outcome.await()) + assertEquals(225L, outcome.atEpochMillis) + assertTrue(outcome.bookkeepingRecorded) + assertEquals(FetchDisposition.Failed, ticket.disposition.value) + assertEquals( + listOf( + BookkeepingEvent.Failure(KeyId.from(key), atEpochMillis = 225L), + BookkeepingEvent.Forget(KeyId.from(key)), + ), + bookkeeper.events, + ) + assertNull(bookkeeper.status(key)) + assertEquals(1, sourceOfTruth.deleteCalls) + assertNull(sourceOfTruth.current) + val state = engine.state.value + assertEquals(1L, state.staleEpoch) + assertEquals(1L, state.clearEpoch) + assertEquals(1L, state.readerGen) + assertNull(state.attribution) + assertEquals(FetchSlot.Idle, state.fetch) + + val missing = + assertFailsWith { + engine.get(Freshness.LocalOnly) + } + assertIs(missing.error) + runCurrent() + assertEquals(2, bookkeeper.events.size) + } + + @Test + fun clearDeleteFailure_preservesResidenceEpochsAndBookkeeping() = runTest { + val boom = IllegalStateException("delete rejected") + val sourceOfTruth = AdapterFailureSourceOfTruth() + val bookkeeper = RecordingBookkeeper() + val clock = FakeWallClock(now = 300L) + val key = TestKey("key") + val engine = + engine( + key = key, + sourceOfTruth = sourceOfTruth, + bookkeeper = bookkeeper, + clock = clock, + ) { FetcherResult.Success("seed", etag = "seed-etag") } + + assertEquals("seed", engine.get(Freshness.CachedOrFetch)) + engine.invalidate() + val stateBefore = engine.state.value + val statusBefore = assertNotNull(bookkeeper.status(key)) + val eventsBefore = bookkeeper.events.toList() + assertEquals("seed", sourceOfTruth.current) + sourceOfTruth.deleteFailure = boom + + val failure = assertFailsWith { engine.clear() } + val persistence = assertIs(failure.error) + assertTrue(persistence.cause === boom) + assertTrue(persistence.message.contains("retry clear()")) + + val stateAfter = engine.state.value + assertEquals(stateBefore.staleEpoch, stateAfter.staleEpoch) + assertEquals(stateBefore.clearEpoch, stateAfter.clearEpoch) + assertEquals(stateBefore.readerGen, stateAfter.readerGen) + assertEquals(stateBefore.fetch, stateAfter.fetch) + assertTrue(stateAfter.attribution === stateBefore.attribution) + assertTrue(bookkeeper.status(key) === statusBefore) + assertEquals(eventsBefore, bookkeeper.events) + assertEquals(1, sourceOfTruth.deleteCalls) + assertEquals("seed", sourceOfTruth.current) + assertEquals("seed", engine.get(Freshness.LocalOnly)) + } + + @Test + fun successfulReturnConvergesFinalWriterOverMutationEraIntermediate() = runTest { + val sourceOfTruth = GatedWriteSourceOfTruth() + val key = TestKey("key") + val store = store { + persistence(sourceOfTruth) + fetcher { "fetched" } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val collector = store.stream(key).testIn(backgroundScope) + assertEquals("seed", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + withContext(Dispatchers.Default) { + sourceOfTruth.writeStarted.await() + } + + sourceOfTruth.publishExternal("external") + runCurrent() + collector.expectNoEvents() + sourceOfTruth.releaseWrite.complete(Unit) + runCurrent() + val committed = assertIs>(collector.awaitItem()) + assertEquals("fetched", committed.value) + assertEquals(Origin.FETCHER, committed.origin) + assertEquals("fetched", store.get(key, Freshness.LocalOnly)) + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseWrite.complete(Unit) + store.close() + } + } + + private fun TestScope.engine( + key: TestKey, + sourceOfTruth: SourceOfTruth, + bookkeeper: Bookkeeper, + clock: FakeWallClock, + fetcher: suspend (TestKey) -> FetcherResult, + ): KeyEngine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher(fetcher), + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = clock, + engineScope = backgroundScope, + ) + + private sealed interface BookkeepingEvent { + data class Success( + val key: KeyId, + val atEpochMillis: Long, + ) : BookkeepingEvent + + data class Failure( + val key: KeyId, + val atEpochMillis: Long, + ) : BookkeepingEvent + + data class Forget( + val key: KeyId, + ) : BookkeepingEvent + } + + private class RecordingBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + val events = mutableListOf() + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + events += BookkeepingEvent.Success(KeyId.from(key), meta.writtenAtEpochMillis) + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + events += BookkeepingEvent.Failure(KeyId.from(key), atEpochMillis) + delegate.recordFailure(key, atEpochMillis) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + events += BookkeepingEvent.Forget(KeyId.from(key)) + delegate.forget(key) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class FailingReaderSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + private var factoryFailuresRemaining: Int = 0 + private var collectionFailuresRemaining: Int = 0 + private var emitBeforeFirstCollectionFailure: Boolean = false + private var trackingRecovery: Boolean = false + var recovered: CompletableDeferred = CompletableDeferred() + private set + var readerCalls: Int = 0 + + fun beginFactoryOutage(failures: Int) { + factoryFailuresRemaining = failures + trackingRecovery = true + recovered = CompletableDeferred() + readerCalls = 0 + } + + fun beginCollectionOutage( + failures: Int, + emitBeforeFirstFailure: Boolean, + ) { + collectionFailuresRemaining = failures + emitBeforeFirstCollectionFailure = emitBeforeFirstFailure + trackingRecovery = true + recovered = CompletableDeferred() + readerCalls = 0 + } + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (factoryFailuresRemaining > 0) { + factoryFailuresRemaining -= 1 + throw IllegalStateException("reader factory failed") + } + val shouldFail = collectionFailuresRemaining > 0 + val emitFirst = emitBeforeFirstCollectionFailure && collectionFailuresRemaining == 3 + if (shouldFail) collectionFailuresRemaining -= 1 + if (!shouldFail && trackingRecovery) { + recovered.complete(Unit) + trackingRecovery = false + } + return flow { + if (emitFirst) emit(rows.value) + if (shouldFail) throw IllegalStateException("reader collection failed") + rows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class GatedWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + val writeStarted = CompletableDeferred() + val releaseWrite = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return flow { + rows.collect { row -> + emit(row) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + writeStarted.complete(Unit) + releaseWrite.await() + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + + fun publishExternal(value: String) { + rows.value = value + } + } + + private class GatedFailingWriteSourceOfTruth( + private val failure: Throwable, + ) : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + val writeStarted = CompletableDeferred() + val releaseWrite = CompletableDeferred() + var deleteCalls: Int = 0 + private set + val current: String? + get() = rows.value + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeStarted.complete(Unit) + releaseWrite.await() + throw failure + } + + override suspend fun delete(key: TestKey) { + deleteCalls += 1 + rows.value = null + } + + fun publishExternal(value: String) { + rows.value = value + } + } + + private class AdapterFailureSourceOfTruth( + private var readerFailure: Throwable? = null, + private var writeFailure: Throwable? = null, + var deleteFailure: Throwable? = null, + ) : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + var deleteCalls: Int = 0 + private set + val current: String? + get() = rows.value + + override fun reader(key: TestKey): Flow { + readerFailure?.let { throw it } + return rows + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeFailure?.let { throw it } + rows.value = value + } + + override suspend fun delete(key: TestKey) { + deleteCalls += 1 + deleteFailure?.let { throw it } + rows.value = null + } + + fun recoverReaderWith(value: String) { + rows.value = value + readerFailure = null + } + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/MaintenanceTestFakes.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/MaintenanceTestFakes.kt new file mode 100644 index 000000000..a854bf363 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/MaintenanceTestFakes.kt @@ -0,0 +1,133 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class RecordingBookkeeper( + private val delegate: Bookkeeper = InMemoryBookkeeper(), + private val events: MutableList = mutableListOf(), + var markStaleFailure: Throwable? = null, + var advanceWatermarkFailure: Throwable? = null, + var forgetFailure: Throwable? = null, + var forgetNamespaceFailure: Throwable? = null, + var forgetAllFailure: Throwable? = null, +) : Bookkeeper by delegate { + val log: List get() = events + var markEntered: CompletableDeferred? = null + var releaseMark: CompletableDeferred? = null + var successEntered: CompletableDeferred? = null + var releaseSuccess: CompletableDeferred? = null + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + successEntered?.complete(Unit) + releaseSuccess?.await() + events += "recordSuccess:${key.namespace.value}/${key.canonicalId()}" + delegate.recordSuccess(key, meta) + } + + override suspend fun status(key: StoreKey) = + delegate.status(key).also { + events += "status:${key.namespace.value}/${key.canonicalId()}" + } + + override suspend fun markStale(key: StoreKey) { + markEntered?.complete(Unit) + releaseMark?.await() + events += "markStale:${key.namespace.value}/${key.canonicalId()}" + markStaleFailure?.let { throw it } + delegate.markStale(key) + } + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) { + events += "advanceStaleWatermark:${namespace.value}" + advanceWatermarkFailure?.let { throw it } + delegate.advanceStaleWatermark(namespace) + } + + override suspend fun advanceGlobalStaleWatermark() { + events += "advanceGlobalStaleWatermark" + advanceWatermarkFailure?.let { throw it } + delegate.advanceGlobalStaleWatermark() + } + + override suspend fun forget(key: StoreKey) { + events += "forget:${key.namespace.value}/${key.canonicalId()}" + forgetFailure?.let { throw it } + delegate.forget(key) + } + + override suspend fun forgetNamespace(namespace: StoreNamespace) { + events += "forgetNamespace:${namespace.value}" + forgetNamespaceFailure?.let { throw it } + delegate.forgetNamespace(namespace) + } + + override suspend fun forgetAll() { + events += "forgetAll" + forgetAllFailure?.let { throw it } + delegate.forgetAll() + } +} + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal open class RecordingSourceOfTruth( + protected val delegate: SourceOfTruth, + private val events: MutableList = mutableListOf(), + var deleteFailure: Throwable? = null, + var deleteNamespaceFailure: Throwable? = null, + var deleteAllFailure: Throwable? = null, +) : SourceOfTruth by delegate { + val log: List get() = events + + override suspend fun delete(key: K) { + deleteFailure?.let { throw it } + events += "delete:${key.namespace.value}/${key.canonicalId()}" + delegate.delete(key) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + deleteNamespaceFailure?.let { throw it } + events += "deleteNamespace:${namespace.value}" + delegate.deleteNamespace(namespace) + } + + override suspend fun deleteAll() { + deleteAllFailure?.let { throw it } + events += "deleteAll" + delegate.deleteAll() + } +} + +/** Holds a bulk delete after it is durable, including if the caller is cancelled. */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class PostDeleteGateSourceOfTruth( + delegate: SourceOfTruth, +) : RecordingSourceOfTruth(delegate) { + val namespaceDeleted = CompletableDeferred() + val allDeleted = CompletableDeferred() + val releaseDelete = CompletableDeferred() + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + super.deleteNamespace(namespace) + withContext(NonCancellable) { + namespaceDeleted.complete(Unit) + releaseDelete.await() + } + } + + override suspend fun deleteAll() { + super.deleteAll() + withContext(NonCancellable) { + allDeleted.complete(Unit) + releaseDelete.await() + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/NamespacedTestKey.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/NamespacedTestKey.kt new file mode 100644 index 000000000..e3c9651cb --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/NamespacedTestKey.kt @@ -0,0 +1,10 @@ +package org.mobilenativefoundation.store6.core + +class NamespacedTestKey( + ns: String, + private val id: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace(ns) + + override fun canonicalId(): String = id +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/OverlayConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/OverlayConformanceTest.kt new file mode 100644 index 000000000..4fc51b95e --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/OverlayConformanceTest.kt @@ -0,0 +1,232 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.seam.Overlay +import org.mobilenativefoundation.store6.core.seam.runtime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class OverlayConformanceTest { + private class TestOverlay( + var transform: (String?) -> String?, + val signals: MutableSharedFlow = MutableSharedFlow(replay = 1), + ) : Overlay { + // Transform mutation precedes emit, and emit -> collect supplies ordering. Replay tolerates + // the projection writer's subscription racing the first signal. + override fun apply( + key: TestKey, + base: String?, + ): String? = transform(base) + + override val changes: Flow + get() = signals + } + + private suspend fun ReceiveTurbine>.awaitDataValue( + expected: String, + ): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == expected) return item + } + } + + private suspend fun recordUntil( + store: Store, + key: TestKey, + script: suspend () -> Unit, + terminal: String, + ): List { + val recorded = mutableListOf() + var scriptRan = false + store.stream(key).test { + while (true) { + when (val item = awaitItem()) { + is StoreResult.Loading -> recorded += "loading" + is StoreResult.Data -> { + recorded += + "data(${item.value},${item.origin},stale=${item.isStale})" + if (item.value == terminal) { + cancelAndIgnoreRemainingEvents() + return@test + } + if (!scriptRan) { + scriptRan = true + script() + } + } + is StoreResult.Revalidated -> recorded += "revalidated" + is StoreResult.Error -> recorded += "error" + } + } + } + return recorded + } + + @Test + fun identityDefault_emissionSequenceUnchanged() = runTest { + var plainFetches = 0 + var projectedFetches = 0 + val plain = store { fetcher { "v${++plainFetches}" } } + val passThrough = store { + fetcher { "v${++projectedFetches}" } + overlay( + object : Overlay { + override fun apply( + key: TestKey, + base: String?, + ): String? = base + + override val changes: Flow = emptyFlow() + }, + ) + } + val key = TestKey("1") + + try { + val plainSequence = + recordUntil( + store = plain, + key = key, + script = { plain.invalidate(key) }, + terminal = "v2", + ) + val projectedSequence = + recordUntil( + store = passThrough, + key = key, + script = { passThrough.invalidate(key) }, + terminal = "v2", + ) + + assertEquals(plainSequence, projectedSequence) + } finally { + plain.close() + passThrough.close() + } + } + + @Test + fun modifyingOverlay_stampsOverlayOrigin() = runTest { + val store = store { + fetcher { "v" } + overlay(TestOverlay({ it?.uppercase() })) + } + + try { + store.stream(TestKey("1")).test { + val data = awaitDataValue("V") + assertEquals(Origin.OVERLAY, data.origin) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun changeSignal_reprojectsForActiveCollectors() = runTest { + val overlay = TestOverlay({ it }) + val store = store { + fetcher { "v" } + overlay(overlay) + } + + try { + store.stream(TestKey("1")).test { + awaitDataValue("v") + overlay.transform = { base -> base + "+pending" } + overlay.signals.emit(TestKey("1")) + val projected = awaitDataValue("v+pending") + assertEquals(Origin.OVERLAY, projected.origin) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun absentBase_overlayProjectionServesCreate() = runTest { + val store = store { + fetcher { awaitCancellation() } + overlay(TestOverlay({ it ?: "pending-create" })) + } + + try { + store.stream(TestKey("1")).test { + val data = awaitDataValue("pending-create") + assertEquals(Origin.OVERLAY, data.origin) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun nullProjection_overResident_emitsAbsentTransition() = runTest { + val overlay = TestOverlay({ it }) + val store = store { + fetcher { "v" } + overlay(overlay) + } + + try { + store.stream(TestKey("1")).test { + awaitDataValue("v") + overlay.transform = { null } + overlay.signals.emit(TestKey("1")) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun retireAfterConfirmedCommit_neverReemitsOldBase() = runTest { + val overlay = TestOverlay({ base -> base?.plus("+op") }) + val store = store { + fetcher { "v1" } + overlay(overlay) + } + val key = TestKey("1") + + try { + store.stream(key).test { + awaitDataValue("v1+op") + cancelAndIgnoreRemainingEvents() + } + store.runtime()!!.writeHandle.apply(key, "v2") + overlay.transform = { it } + overlay.signals.emit(TestKey("1")) + + store.stream(key).test { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + assertTrue(item.value != "v1" && item.value != "v1+op") + if (item.value == "v2") { + assertTrue(item.origin == Origin.SOT || item.origin == Origin.MEMORY) + cancelAndIgnoreRemainingEvents() + break + } + } + } + } + } finally { + store.close() + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/PublicSurfaceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/PublicSurfaceTest.kt new file mode 100644 index 000000000..000eefafb --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/PublicSurfaceTest.kt @@ -0,0 +1,85 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +class PublicSurfaceTest { + + /** Detector fixture: an unstable canonicalId fails fast and names the fix. */ + private class UnstableKey : StoreKey { + private var counter = 0 + override val namespace: StoreNamespace = StoreNamespace("unstable") + override fun canonicalId(): String = "id-${counter++}" + } + + @Test + fun unstableCanonicalId_failsFastNamingTheFix() = runTest { + val store = store { fetcher { "v" } } + val failure = assertFailsWith { store.get(UnstableKey()) } + assertTrue(failure.message!!.contains("canonicalId")) + assertTrue(failure.message!!.contains("unstable")) // names the namespace + assertTrue(failure.message!!.contains("stable")) // names the fix + store.close() + } + + /** Frozen-surface compile lock: every result/error variant constructible via internal ctors. */ + @Test + fun frozenResultAndErrorVariants_constructibleWithFullPayloads() { + val data = StoreResult.Data( + value = "v", + origin = Origin.MEMORY, + age = Duration.ZERO, + isStale = false, + refreshing = false, + ) + assertEquals("v", data.value) + + StoreResult.Loading() + assertEquals(Duration.ZERO, StoreResult.Revalidated(age = Duration.ZERO).age) + + val errors: List = listOf( + StoreError.Fetch(message = "m", cause = null), + StoreError.Persistence(message = "m", cause = null), + StoreError.Conversion(message = "m", cause = null), + StoreError.FreshnessUnsatisfiable(message = "m"), + StoreError.Conflict(serverMeta = null, message = "m"), + StoreError.Missing(key = TestKey("1"), message = "m"), + ) + val wrapped = StoreResult.Error(error = errors.first(), servedStale = false) + assertIs(wrapped.error) + + // messageOf is exhaustive over the frozen set. + errors.forEach { error -> + assertEquals("m", StoreException(error).message) + } + } + + /** Freshness values are accepted everywhere and apply their documented postures. */ + @Test + fun allFreshnessValues_acceptedWithHonestPostures() = runTest { + val store = store { fetcher { "v" } } + val policies = listOf( + Freshness.CachedOrFetch, + Freshness.MaxAge(notOlderThan = 5.minutes), // public constructor + Freshness.MustBeFresh, + Freshness.StaleIfError, + Freshness.LocalOnly, + ) + for (policy in policies) { + assertEquals("v", store.get(TestKey("k"), policy)) + } + store.stream(TestKey("k"), Freshness.MustBeFresh).test { + assertIs(awaitItem()) + assertIs>(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + store.close() + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderGenerationRecoveryTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderGenerationRecoveryTest.kt new file mode 100644 index 000000000..40d2749af --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderGenerationRecoveryTest.kt @@ -0,0 +1,142 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.internal.RotatingSlotSourceOfTruth +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class ReaderGenerationRecoveryTest { + private val key = TestKey("rotating-slot") + + @Test + fun readerGen_clear_reconnectsRotatingSlotReader() = runTest { + val sourceOfTruth = RotatingSlotSourceOfTruth() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + when (++fetchCalls) { + 1 -> "v1" + 2 -> { + sourceOfTruth.awaitCurrentSlotReaderDelivery(key) + "v2" + } + else -> error("unexpected fetch call $fetchCalls") + } + } + } + + try { + store.stream(key).test { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == "v1") break + } + runCurrent() + val subscriptionsBeforeClear = sourceOfTruth.subscriptionCount(key) + + store.clear(key) + awaitSubscriptionAfter(sourceOfTruth, subscriptionsBeforeClear) + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == "v2") break + } + assertEquals(2, fetchCalls) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun readerGen_serverDelete_reconnectsRotatingSlotReader() = runTest { + val sourceOfTruth = RotatingSlotSourceOfTruth() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcherOfResult { + when (++fetchCalls) { + 1 -> FetcherResult.Success("v1") + 2 -> FetcherResult.Deleted + else -> error("unexpected fetch call $fetchCalls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("v1", assertIs>(observer.awaitItem()).value) + runCurrent() + val subscriptionsBeforeDelete = sourceOfTruth.subscriptionCount(key) + + val deletion = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { store.get(key, Freshness.MustBeFresh) } + } + runCurrent() + val failure = assertIs(deletion.await().exceptionOrNull()) + assertIs(failure.error) + awaitSubscriptionAfter(sourceOfTruth, subscriptionsBeforeDelete) + + sourceOfTruth.write(key, "v2") + runCurrent() + while (true) { + val item = observer.awaitItem() + if (item is StoreResult.Data && item.value == "v2") break + } + assertEquals(2, fetchCalls) + observer.cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + private suspend fun awaitSubscriptionAfter( + sourceOfTruth: RotatingSlotSourceOfTruth, + previousCount: Int, + ) { + // Preserve the real-time Default-dispatch hop and let the suite-level runTest bound own + // cancellation. + withContext(Dispatchers.Default) { + while (sourceOfTruth.subscriptionCount(key) <= previousCount) { + yield() + } + } + assertTrue( + sourceOfTruth.subscriptionCount(key) > previousCount, + "readerGen must attach to the rotated durable slot", + ) + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderHopSourceOfTruth.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderHopSourceOfTruth.kt new file mode 100644 index 000000000..61b263c9e --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/ReaderHopSourceOfTruth.kt @@ -0,0 +1,51 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +/** + * Adversarial test decorator that adds a real-dispatcher hop and yield to widen the + * queued-reader-frame race. + * + * SQLDelight's `readContext` switches before row capture, so its adversarial lane uses an + * equivalent post-capture decorator instead of treating `Default` versus `EmptyCoroutineContext` + * as this seam. This decorator is test-only and unpublished; mutations are left untouched. + */ +internal class ReaderHopSourceOfTruth( + private val delegate: SourceOfTruth, +) : SourceOfTruth { + override fun reader(key: K): Flow = + delegate.reader(key) + .map { + yield() + it + }.flowOn(Dispatchers.Default) + + override suspend fun write( + key: K, + value: V, + ) { + delegate.write(key, value) + } + + override suspend fun delete(key: K) { + delegate.delete(key) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + delegate.deleteNamespace(namespace) + } + + override suspend fun deleteAll() { + delegate.deleteAll() + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SchedulerPerturbationRuns.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SchedulerPerturbationRuns.kt new file mode 100644 index 000000000..e89c9bc97 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SchedulerPerturbationRuns.kt @@ -0,0 +1,29 @@ +@file:OptIn(ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.core + +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth + +class StoreInvalidationConformanceUnderReaderHopTest : StoreInvalidationConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence( + trackedSot(ReaderHopSourceOfTruth(InMemorySourceOfTruth())), + ) + } +} + +class EmissionSequenceConformanceUnderReaderHopTest : EmissionSequenceConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence( + trackedSot(ReaderHopSourceOfTruth(InMemorySourceOfTruth())), + ) + } +} + +class FreshnessPolicyConformanceUnderReaderHopTest : FreshnessPolicyConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence( + trackedSot(ReaderHopSourceOfTruth(InMemorySourceOfTruth())), + ) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SeamFetcherTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SeamFetcherTest.kt new file mode 100644 index 000000000..6dbc85029 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SeamFetcherTest.kt @@ -0,0 +1,40 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class SeamFetcherTest { + @Test + fun seamFetcher_receivesEtagOnConditionalPlan() = runTest { + // etags is mutated on the fetch coroutine (Dispatchers.Default) and read here only after the + // corresponding get() returned — the FetchTicket completion edge orders every mutation + // before the read (the T3 placement pin formalizes this happens-before). + val etags = mutableListOf() + val store = + store { + fetcher( + object : Fetcher { + override suspend fun fetch( + key: TestKey, + etag: String?, + ): FetcherResult { + etags += etag + return FetcherResult.Success( + "v${etags.size}", + etag = "tag-${etags.size}", + ) + } + }, + ) + } + + assertEquals("v1", store.get(TestKey("1"))) + assertEquals("v2", store.get(TestKey("1"), Freshness.MustBeFresh)) + assertEquals(listOf(null, "tag-1"), etags) + store.close() + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SingleFlightConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SingleFlightConformanceTest.kt new file mode 100644 index 000000000..1775058ed --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SingleFlightConformanceTest.kt @@ -0,0 +1,108 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +open class SingleFlightConformanceTest : SourceOfTruthSubstitutionTest() { + @Test + fun ac2_fiftyGettersAndFiftyCollectorsShareOneFetch() = runTest { + var calls = 0 + val started = CompletableDeferred() + val gate = CompletableDeferred() + val store = testStore { + fetcher { + calls++ + started.complete(Unit) + gate.await() + "v" + } + } + val key = TestKey("1") + + try { + val getters = + List(50) { + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key) + } + } + val collectorRegistered = List(50) { CompletableDeferred() } + val collectors = + List(50) { index -> + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + val item = + store + .stream(key) + .onEach { result -> + if (result is StoreResult.Loading) { + collectorRegistered[index].complete(Unit) + } + } + .first { it is StoreResult.Data<*> } + assertIs>(item).value + } + } + + started.await() + // A channelFlow producer may start after the fetcher's signal. Loading proves each + // stream demand joined the still-gated ticket before the fetch is allowed to settle. + collectorRegistered.awaitAll() + assertEquals(1, calls) + gate.complete(Unit) + + assertTrue(getters.awaitAll().all { it == "v" }) + assertTrue(collectors.awaitAll().all { it == "v" }) + assertEquals(1, calls) + } finally { + store.close() + } + } + + @Test + fun cancelledWaiterDoesNotCancelSharedFetch() = runTest { + var calls = 0 + val started = CompletableDeferred() + val gate = CompletableDeferred() + val store = testStore { + fetcher { + calls++ + started.complete(Unit) + gate.await() + "v" + } + } + val key = TestKey("1") + + try { + val cancelled = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key) + } + started.await() + cancelled.cancelAndJoin() + assertTrue(cancelled.isCancelled) + + val later = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key) + } + gate.complete(Unit) + + assertEquals("v", later.await()) + assertEquals("v", store.get(key)) + assertEquals(1, calls) + } finally { + store.close() + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthAdditionalRaceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthAdditionalRaceTest.kt new file mode 100644 index 000000000..d420db16b --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthAdditionalRaceTest.kt @@ -0,0 +1,510 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.testIn +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.DefaultFreshnessValidator +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.KeyEngine +import org.mobilenativefoundation.store6.core.internal.KeyId +import org.mobilenativefoundation.store6.core.internal.LambdaFetcher +import org.mobilenativefoundation.store6.core.internal.READER_PIPELINE_GRACE_MILLIS +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes + +@OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class SourceOfTruthAdditionalRaceTest { + private val key = TestKey("additional-race") + + @Test + fun externalWriteReturned_whileGraceReplayAbsent_streamServesDurableBeforeFetch() = runTest { + val sourceOfTruth = GraceReplaySourceOfTruth(initial = null) + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var fetchCalls = 0 + val engine = + newEngine(sourceOfTruth) { + fetchCalls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + "fetched" + } + + try { + app.cash.turbine.turbineScope { + val first = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertIs( + assertIs(first.awaitItem()).error, + ) + sourceOfTruth.liveReaderStarted.await() + runCurrent() + first.cancelAndIgnoreRemainingEvents() + advanceToLastReaderGraceMillisecond() + + sourceOfTruth.gateNextLiveDelivery() + sourceOfTruth.write(key, "durable") + sourceOfTruth.liveDeliveryBlocked.await() + + val second = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + val durable = assertIs>(second.awaitItem()) + assertEquals("durable", durable.value) + assertEquals(Origin.SOT, durable.origin) + assertTrue(durable.isStale) + assertTrue(durable.refreshing) + fetchStarted.await() + assertEquals(1, fetchCalls) + runCurrent() + + sourceOfTruth.releaseLiveDelivery.complete(Unit) + runCurrent() + releaseFetch.complete(Unit) + while (true) { + val item = second.awaitItem() + if (item is StoreResult.Data && item.value == "fetched") break + } + second.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseLiveDelivery.complete(Unit) + releaseFetch.complete(Unit) + } + } + + @Test + fun externalDeleteReturned_whileGraceHasOldMemory_streamConvergesMemoryThenLoading() = runTest { + val sourceOfTruth = GraceReplaySourceOfTruth(initial = "seed") + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var fetchCalls = 0 + val engine = + newEngine(sourceOfTruth) { + fetchCalls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + "fresh" + } + + try { + app.cash.turbine.turbineScope { + val first = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(first.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + runCurrent() + first.cancelAndIgnoreRemainingEvents() + advanceToLastReaderGraceMillisecond() + + sourceOfTruth.gateNextLiveDelivery() + sourceOfTruth.delete(key) + sourceOfTruth.liveDeliveryBlocked.await() + + val second = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + val memory = assertIs>(second.awaitItem()) + assertEquals("seed", memory.value) + assertEquals(Origin.MEMORY, memory.origin) + fetchStarted.await() + runCurrent() + + sourceOfTruth.releaseLiveDelivery.complete(Unit) + assertIs(second.awaitItem()) + releaseFetch.complete(Unit) + while (true) { + val item = second.awaitItem() + if (item is StoreResult.Data && item.value == "fresh") break + } + assertEquals(1, fetchCalls) + second.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseLiveDelivery.complete(Unit) + releaseFetch.complete(Unit) + } + } + + @Test + fun sameGenerationRowReplay_afterResidenceAdvances_neverRegresses() = runTest { + val sourceOfTruth = GraceReplaySourceOfTruth(initial = "old") + val engine = newEngine(sourceOfTruth) { "fresh" } + + try { + app.cash.turbine.turbineScope { + val first = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("old", assertIs>(first.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + runCurrent() + first.cancelAndIgnoreRemainingEvents() + advanceToLastReaderGraceMillisecond() + + sourceOfTruth.gateNextLiveDelivery() + val refresh = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.get(Freshness.MustBeFresh) + } + sourceOfTruth.liveDeliveryBlocked.await() + assertEquals("fresh", refresh.await()) + + val second = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("fresh", assertIs>(second.awaitItem()).value) + runCurrent() + second.expectNoEvents() + + sourceOfTruth.releaseLiveDelivery.complete(Unit) + runCurrent() + sourceOfTruth.publishExternal("authoritative-row") + val authoritative = assertIs>(second.awaitItem()) + assertEquals("authoritative-row", authoritative.value) + assertEquals(Origin.SOT, authoritative.origin) + second.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseLiveDelivery.complete(Unit) + } + } + + @Test + fun sameGenerationAbsentReplay_afterResidenceAdvances_neverEmitsFalseLoading() = runTest { + val sourceOfTruth = GraceReplaySourceOfTruth(initial = null) + val engine = newEngine(sourceOfTruth) { "fresh" } + + try { + app.cash.turbine.turbineScope { + val first = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertIs( + assertIs(first.awaitItem()).error, + ) + sourceOfTruth.liveReaderStarted.await() + runCurrent() + first.cancelAndIgnoreRemainingEvents() + advanceToLastReaderGraceMillisecond() + + sourceOfTruth.gateNextLiveDelivery() + val refresh = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.get(Freshness.MustBeFresh) + } + sourceOfTruth.liveDeliveryBlocked.await() + assertEquals("fresh", refresh.await()) + + val second = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("fresh", assertIs>(second.awaitItem()).value) + runCurrent() + second.expectNoEvents() + + sourceOfTruth.releaseLiveDelivery.complete(Unit) + runCurrent() + sourceOfTruth.publishExternal("authoritative-after-absent") + val authoritative = assertIs>(second.awaitItem()) + assertEquals("authoritative-after-absent", authoritative.value) + assertEquals(Origin.SOT, authoritative.origin) + second.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseLiveDelivery.complete(Unit) + } + } + + @Test + fun queuedAbsent_consumesTag_andLaterEqualRowDoesNotInheritFetcher() = runTest { + val sourceOfTruth = QueuedAbsentThenWriterSourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { "candidate" } + } + + try { + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + runCurrent() + + val fetched = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key, Freshness.MustBeFresh) + } + sourceOfTruth.queuedAbsentEmitted.await() + assertIs(observer.awaitItem()) + assertIs( + assertIs(observer.awaitItem()).error, + ) + + sourceOfTruth.releaseWriterEcho.complete(Unit) + assertEquals("candidate", fetched.await()) + val writer = assertIs>(observer.awaitItem()) + assertEquals("candidate", writer.value) + assertEquals(Origin.FETCHER, writer.origin) + + sourceOfTruth.publishExternal(null) + assertIs(observer.awaitItem()) + assertIs( + assertIs(observer.awaitItem()).error, + ) + sourceOfTruth.publishExternal("candidate") + val external = assertIs>(observer.awaitItem()) + assertEquals("candidate", external.value) + assertEquals(Origin.SOT, external.origin) + observer.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseWriterEcho.complete(Unit) + store.close() + } + } + + @Test + fun mustBeFresh_externalWithheldRow_transitionsDataToLoadingBeforeRefresh() = runTest { + val sourceOfTruth = ReactiveSourceOfTruth(initial = null) + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + when (++fetchCalls) { + 1 -> "v1" + 2 -> { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() + "v2" + } + + else -> error("unexpected fetch call $fetchCalls") + } + } + } + + try { + app.cash.turbine.turbineScope { + val collector = + store.stream(key, Freshness.MustBeFresh).testIn(backgroundScope) + assertIs(collector.awaitItem()) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + runCurrent() + + sourceOfTruth.publishExternal("external") + secondFetchStarted.await() + assertIs(collector.awaitItem()) + + releaseSecondFetch.complete(Unit) + val refreshed = assertIs>(collector.awaitItem()) + assertEquals("v2", refreshed.value) + assertEquals(Origin.FETCHER, refreshed.origin) + assertEquals(2, fetchCalls) + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecondFetch.complete(Unit) + store.close() + } + } + + @Test + fun maxAge_externalWithheldRow_transitionsDataToLoadingBeforeRefresh() = runTest { + val sourceOfTruth = ReactiveSourceOfTruth(initial = null) + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + when (++fetchCalls) { + 1 -> "v1" + 2 -> { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() + "v2" + } + + else -> error("unexpected fetch call $fetchCalls") + } + } + } + + try { + app.cash.turbine.turbineScope { + val collector = + store.stream(key, Freshness.MaxAge(notOlderThan = 5.minutes)) + .testIn(backgroundScope) + assertIs(collector.awaitItem()) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + runCurrent() + + sourceOfTruth.publishExternal("external") + secondFetchStarted.await() + assertIs(collector.awaitItem()) + + releaseSecondFetch.complete(Unit) + val refreshed = assertIs>(collector.awaitItem()) + assertEquals("v2", refreshed.value) + assertEquals(Origin.FETCHER, refreshed.origin) + assertEquals(2, fetchCalls) + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecondFetch.complete(Unit) + store.close() + } + } + + /** Keeps the shared reader alive through grace while allowing direct one-shot RYW probes. */ + private class GraceReplaySourceOfTruth( + initial: String?, + ) : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow(extraBufferCapacity = 8) + private var current: String? = initial + private var gateNextLive = false + + val liveReaderStarted = CompletableDeferred() + var liveDeliveryBlocked = CompletableDeferred() + private set + var releaseLiveDelivery = CompletableDeferred() + private set + + fun gateNextLiveDelivery() { + check(liveReaderStarted.isCompleted) { + "the shared reader must be active before gating delivery" + } + check(!gateNextLive) { "a live delivery is already gated" } + gateNextLive = true + liveDeliveryBlocked = CompletableDeferred() + releaseLiveDelivery = CompletableDeferred() + } + + override fun reader(key: TestKey): Flow = + flow { + // `first()` aborts during this emit, so only the long-lived pipeline reaches the + // live tail and marks itself ready. Every full collection still remains live. + emit(current) + liveReaderStarted.complete(Unit) + liveRows.collect { row -> + if (gateNextLive) { + gateNextLive = false + liveDeliveryBlocked.complete(Unit) + releaseLiveDelivery.await() + } + emit(row) + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + current = value + liveRows.emit(value) + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + + suspend fun publishExternal(value: String) { + current = value + liveRows.emit(value) + } + } + + private fun TestScope.newEngine( + sourceOfTruth: SourceOfTruth, + fetcher: suspend () -> String, + ): KeyEngine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = LambdaFetcher { fetcher() }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + /** Arms the 100ms stop timer, then stays one virtual millisecond inside its grace window. */ + private fun TestScope.advanceToLastReaderGraceMillisecond() { + runCurrent() + advanceTimeBy(READER_PIPELINE_GRACE_MILLIS - 1L) + } + + private class QueuedAbsentThenWriterSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableSharedFlow(replay = 1) + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + val queuedAbsentEmitted = CompletableDeferred() + val releaseWriterEcho = CompletableDeferred() + + init { + check(rows.tryEmit("seed")) + } + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return rows + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.emit(null) + queuedAbsentEmitted.complete(Unit) + releaseWriterEcho.await() + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + suspend fun publishExternal(value: String?) { + rows.emit(value) + } + } + + private class ReactiveSourceOfTruth( + initial: String?, + ) : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(initial) + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return rows + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + + fun publishExternal(value: String) { + rows.value = value + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthBindingConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthBindingConformanceTest.kt new file mode 100644 index 000000000..2a7cd0759 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthBindingConformanceTest.kt @@ -0,0 +1,2085 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.produceIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.mobilenativefoundation.store6.core.internal.DefaultFreshnessValidator +import org.mobilenativefoundation.store6.core.internal.FetchDisposition +import org.mobilenativefoundation.store6.core.internal.FetchOutcome +import org.mobilenativefoundation.store6.core.internal.FetchSlot +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.KeyEngine +import org.mobilenativefoundation.store6.core.internal.KeyId +import org.mobilenativefoundation.store6.core.internal.ResultFetcher +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class SourceOfTruthBindingConformanceTest { + private val key = TestKey("key") + + @Test + fun hydrationQueuedBeforeClear_cannotResurrectAfterClearCompletes() = runTest { + val sourceOfTruth = GatedHydrationSourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + + try { + val hydration = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key, Freshness.LocalOnly) + } + sourceOfTruth.readerStarted.await() + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.clear(key) + } + runCurrent() + + sourceOfTruth.releaseReader.complete(Unit) + assertEquals("stale", hydration.await()) + clear.await() + + val missing = + assertFailsWith { + store.get(key, Freshness.LocalOnly) + } + assertIs(missing.error) + } finally { + sourceOfTruth.releaseReader.complete(Unit) + store.close() + } + } + + @Test + fun externalReplacementAfterWriteReturn_winsDuringGatedRecordSuccess() = runTest { + val sourceOfTruth = MutableSourceOfTruth(initial = "seed") + val bookkeeper = GateNextSuccessBookkeeper() + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + var fetchCalls = 0 + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcher { + when (val call = ++fetchCalls) { + 1 -> "fetched-1" + 2 -> { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() + "fetched-2" + } + + else -> error("unexpected fetch call $call") + } + } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + bookkeeper.gateNextSuccess() + app.cash.turbine.turbineScope { + val collector = store.stream(key).testIn(backgroundScope) + withContext(Dispatchers.Default) { + bookkeeper.successEntered.await() + } + + sourceOfTruth.publishExternal("external") + awaitLocalValue(store, "external") + bookkeeper.releaseSuccess.complete(Unit) + withContext(Dispatchers.Default) { + secondFetchStarted.await() + } + assertEquals("external", store.get(key, Freshness.LocalOnly)) + + releaseSecondFetch.complete(Unit) + awaitLocalValue(store, "fetched-2") + assertEquals(2, fetchCalls) + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + bookkeeper.releaseSuccess.complete(Unit) + releaseSecondFetch.complete(Unit) + store.close() + } + } + + @Test + fun notModified_externalReplacementObservedAfterLaunch_isObsoleteNotFreshened() = runTest { + val sourceOfTruth = MutableSourceOfTruth() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + var calls = 0 + val store = store { + persistence(sourceOfTruth) + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + FetcherResult.NotModified(etag = "e2") + } + + 3 -> FetcherResult.Success("fresh", etag = "e3") + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("v1", assertIs>(observer.awaitItem()).value) + val fresh = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key, Freshness.MustBeFresh) + } + secondStarted.await() + + sourceOfTruth.publishExternal("external") + var external: StoreResult.Data? = null + while (true) { + val item = observer.awaitItem() + if (item is StoreResult.Data && item.value == "external") { + external = item + break + } + } + assertExternalSotStale(assertNotNull(external)) + releaseSecond.complete(Unit) + + assertEquals("fresh", fresh.await()) + var subsequentSuccess: StoreResult.Data? = null + while (subsequentSuccess == null) { + val item = observer.awaitItem() + if (item is StoreResult.Data) { + when (item.value) { + "external" -> assertExternalSotStale(item) + "fresh" -> subsequentSuccess = item + else -> error("unexpected value after external replacement: ${item.value}") + } + } + } + val success = assertNotNull(subsequentSuccess) + assertEquals(Origin.FETCHER, success.origin) + assertFalse(success.isStale) + assertEquals(3, calls) + observer.cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecond.complete(Unit) + store.close() + } + } + + @Test + fun notModifiedDirectDelivery_revisionMismatchDoesNotEmitReplayedResidence() = runTest { + val sourceOfTruth = ReplayableSourceOfTruth() + val bookkeeper = GateNextSuccessBookkeeper() + var calls = 0 + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + 3 -> FetcherResult.Success("fresh", etag = "e3") + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("v1", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + sourceOfTruth.replay("sync") + observer.awaitDataValue("sync") + sourceOfTruth.replay("v1") + observer.awaitDataValue("v1") + store.invalidate(key) + bookkeeper.gateNextSuccess() + + store.stream(key).test { + val stale = assertIs>(awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + withContext(Dispatchers.Default) { + bookkeeper.successEntered.await() + } + sourceOfTruth.replay("v1") + expectNoEvents() + + sourceOfTruth.publishExternal("external") + var observerExternal: StoreResult.Data? = null + while (observerExternal == null) { + val item = observer.awaitItem() + if (item is StoreResult.Data && item.value == "external") { + observerExternal = item + } + } + assertExternalSotStale(assertNotNull(observerExternal)) + bookkeeper.releaseSuccess.complete(Unit) + + var sawFresh = false + while (!sawFresh) { + when (val item = awaitItem()) { + is StoreResult.Data -> { + if (item.value == "external") assertExternalSotStale(item) + assertFalse( + item.value == "v1" && !item.isStale, + "stale 304 revision must not directly re-emit v1 as fresh", + ) + if (item.value == "fresh") { + assertEquals(Origin.FETCHER, item.origin) + assertFalse(item.isStale) + sawFresh = true + } + } + + is StoreResult.Revalidated -> + fail("obsolete 304 must not emit Revalidated") + + else -> Unit + } + } + assertEquals(3, calls) + cancelAndIgnoreRemainingEvents() + } + observer.cancelAndIgnoreRemainingEvents() + } + } finally { + bookkeeper.releaseSuccess.complete(Unit) + store.close() + } + } + + @Test + fun notModifiedDirect_afterMappedSameValueBaseline_emitsRevalidatedExactlyOnce() = runTest { + val sourceOfTruth = ReplayableSourceOfTruth() + var calls = 0 + val store = store { + persistence(sourceOfTruth) + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("v1", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + sourceOfTruth.replay("sync") + observer.awaitDataValue("sync") + sourceOfTruth.replay("v1") + observer.awaitDataValue("v1") + store.invalidate(key) + + val collector = store.stream(key).testIn(backgroundScope) + assertTrue(assertIs>(collector.awaitItem()).isStale) + assertIs(collector.awaitItem()) + collector.expectNoEvents() + + assertEquals(2, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun sameValueReplayThenNotModifiedDirect_emitsRevalidatedExactlyOnce() = runTest { + val sourceOfTruth = ReplayableSourceOfTruth() + val bookkeeper = GateNextSuccessBookkeeper() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + var calls = 0 + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + FetcherResult.NotModified(etag = "e2") + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("v1", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + sourceOfTruth.replay("sync") + observer.awaitDataValue("sync") + sourceOfTruth.replay("v1") + observer.awaitDataValue("v1") + store.invalidate(key) + bookkeeper.gateNextSuccess() + + val collector = store.stream(key).testIn(backgroundScope) + assertTrue(assertIs>(collector.awaitItem()).isStale) + secondStarted.await() + releaseSecond.complete(Unit) + bookkeeper.successEntered.await() + sourceOfTruth.replay("v1") + collector.expectNoEvents() + bookkeeper.releaseSuccess.complete(Unit) + + assertIs(collector.awaitItem()) + collector.expectNoEvents() + assertEquals(2, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecond.complete(Unit) + bookkeeper.releaseSuccess.complete(Unit) + store.close() + } + } + + @Test + fun lateCollector_exactResidentRevisionEmitsMemoryOnce() = runTest { + val store = store { fetcher { "v1" } } + + try { + assertEquals("v1", store.get(key)) + store.stream(key, Freshness.LocalOnly).test { + val data = assertIs>(awaitItem()) + assertEquals("v1", data.value) + assertEquals(Origin.MEMORY, data.origin) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun externalWriteReturned_whileGraceHasOldMemory_convergesMemoryThenSot() = runTest { + val sourceOfTruth = GraceGateSourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + + try { + app.cash.turbine.turbineScope { + val first = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(first.awaitItem()).value) + withContext(Dispatchers.Default) { + sourceOfTruth.liveReaderStarted.await() + } + first.cancelAndIgnoreRemainingEvents() + + sourceOfTruth.gateExternalEmission() + sourceOfTruth.write(key, "external") + withContext(Dispatchers.Default) { + sourceOfTruth.externalEmissionBlocked.await() + } + + val second = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + val memory = assertIs>(second.awaitItem()) + assertEquals("seed", memory.value) + assertEquals(Origin.MEMORY, memory.origin) + + sourceOfTruth.releaseExternalEmission.complete(Unit) + val external = assertIs>(second.awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + second.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseExternalEmission.complete(Unit) + store.close() + } + } + + @Test + fun fetchFailureAfterReactiveAbsence_reportsServedStaleFalse() = runTest { + val sourceOfTruth = MutableSourceOfTruth() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + val boom = IllegalStateException("fetch failed") + var calls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + throw boom + } + + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + store.invalidate(key) + store.stream(key).test { + val stale = assertIs>(awaitItem()) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + secondStarted.await() + + sourceOfTruth.delete(key) + assertIs(awaitItem()) + releaseSecond.complete(Unit) + + val failure = assertIs(awaitItem()) + assertIs(failure.error) + assertFalse(failure.servedStale) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecond.complete(Unit) + store.close() + } + } + + @Test + fun fetchFailureWhileReactiveStaleValueRemains_reportsServedStaleTrue() = runTest { + val sourceOfTruth = MutableSourceOfTruth() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + var calls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + throw IllegalStateException("fetch failed") + } + + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + store.invalidate(key) + store.stream(key).test { + val stale = assertIs>(awaitItem()) + assertTrue(stale.isStale) + secondStarted.await() + releaseSecond.complete(Unit) + + val failure = assertIs(awaitItem()) + assertIs(failure.error) + assertTrue(failure.servedStale) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecond.complete(Unit) + store.close() + } + } + + @Test + fun externalNullMetaRow_underMaxAgeEmitsLoadingUntilFresh() = runTest { + val sourceOfTruth = MutableSourceOfTruth(initial = "durable") + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val store = store { + persistence(sourceOfTruth) + fetcher { + fetchStarted.complete(Unit) + releaseFetch.await() + "fresh" + } + } + + try { + store.stream(key, Freshness.MaxAge(5.minutes)).test { + assertIs(awaitItem()) + fetchStarted.await() + sourceOfTruth.liveReaderStarted.await() + releaseFetch.complete(Unit) + assertEquals("fresh", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseFetch.complete(Unit) + store.close() + } + } + + @Test + fun serverDeleteFailure_reportsTimestampedPersistence_withoutDestructiveMutation() = runTest { + val boom = IllegalStateException("delete rejected") + val sourceOfTruth = GatedFailingServerDeleteSourceOfTruth(failure = boom) + val bookkeeper = FailureTrackingBookkeeper() + val clock = FakeWallClock(now = 400L) + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.Deleted + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = clock, + engineScope = backgroundScope, + ) + + val seed = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.get(Freshness.CachedOrFetch) + } + runCurrent() + assertEquals("v1", seed.await()) + val stateBefore = engine.state.value + val statusBefore = assertNotNull(bookkeeper.status(key)) + assertEquals("v1", sourceOfTruth.current) + + clock.now = 425L + val deleting = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + val ticket = assertIs(engine.state.value.fetch).ticket + runCurrent() + sourceOfTruth.deleteStarted.await() + assertEquals(1, sourceOfTruth.deleteCalls) + sourceOfTruth.releaseDelete.complete(Unit) + + val failure = assertIs(deleting.await().exceptionOrNull()) + val persistence = assertIs(failure.error) + assertTrue(persistence.cause === boom) + assertTrue(persistence.message.contains("server-side deletion failed")) + val outcome = assertIs(ticket.outcome.await()) + assertEquals(425L, outcome.atEpochMillis) + assertFalse(outcome.bookkeepingRecorded) + assertIs(outcome.exception.error) + assertEquals(FetchDisposition.Failed, ticket.disposition.value) + + val stateAfter = engine.state.value + assertEquals(stateBefore.staleEpoch, stateAfter.staleEpoch) + assertEquals(stateBefore.clearEpoch, stateAfter.clearEpoch) + assertEquals(stateBefore.readerGen, stateAfter.readerGen) + assertTrue(stateAfter.attribution === stateBefore.attribution) + assertEquals(FetchSlot.Idle, stateAfter.fetch) + assertEquals("v1", sourceOfTruth.current) + assertEquals("v1", engine.get(Freshness.LocalOnly)) + assertEquals(listOf(425L), bookkeeper.failureTimes) + assertEquals(0, bookkeeper.forgetCalls) + val statusAfter = assertNotNull(bookkeeper.status(key)) + assertTrue(statusAfter.meta === statusBefore.meta) + assertEquals(statusBefore.lastSuccessSequence, statusAfter.lastSuccessSequence) + assertEquals(425L, statusAfter.lastFailureAtEpochMillis) + assertEquals(1, statusAfter.consecutiveFailures) + } + + @Test + fun clear_waitingForWriteLock_isCancellableBeforeDeleteStarts() = runTest { + val sourceOfTruth = GatedDeleteSourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + val first = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.clear(key) + } + sourceOfTruth.deleteStarted.await() + val second = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.clear(key) + } + runCurrent() + + second.cancel(CancellationException("cancel queued clear")) + sourceOfTruth.releaseDelete.complete(Unit) + first.await() + assertIs(runCatching { second.await() }.exceptionOrNull()) + assertEquals(1, sourceOfTruth.deleteCalls) + } finally { + sourceOfTruth.releaseDelete.complete(Unit) + store.close() + } + } + + @Test + fun clear_afterDeleteStarts_finishesIrreversibleTailUnderCancellation() = runTest { + val sourceOfTruth = GatedDeleteSourceOfTruth() + val bookkeeper = ForgetSignallingBookkeeper() + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.clear(key) + } + sourceOfTruth.deleteStarted.await() + clear.cancel(CancellationException("cancel after delete started")) + sourceOfTruth.releaseDelete.complete(Unit) + withTimeout(2_000L) { bookkeeper.forgotten.await() } + + val missing = + runCatching { store.get(key, Freshness.LocalOnly) } + .exceptionOrNull() as? StoreException + assertIs(missing?.error) + assertEquals(1, sourceOfTruth.deleteCalls) + } finally { + sourceOfTruth.releaseDelete.complete(Unit) + store.close() + } + } + + @Test + fun backpressuredReaderDelivery_doesNotBlockClearMutation() = runTest { + val sourceOfTruth = BackpressureSourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + val collector = + store.stream(key, Freshness.LocalOnly) + .buffer(capacity = 0) + .produceIn(backgroundScope) + + try { + assertEquals("seed", assertIs>(collector.receive()).value) + sourceOfTruth.liveReaderStarted.await() + sourceOfTruth.publishExternal("external") + awaitLocalValue(store, "external") + sourceOfTruth.externalReaderObserved.await() + + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.clear(key) + } + withContext(Dispatchers.Default) { + sourceOfTruth.deleteStarted.await() + } + clear.await() + } finally { + collector.cancel() + store.close() + } + } + + @Test + fun synchronousDeleteEcho_doesNotLaunchFetchBeforeClearTail() = runTest { + val sourceOfTruth = GatedDeleteEchoSourceOfTruth() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { "fetched-${++fetchCalls}" } + } + + try { + assertEquals("fetched-1", store.get(key)) + app.cash.turbine.turbineScope { + val collector = store.stream(key).testIn(backgroundScope) + assertEquals( + "fetched-1", + assertIs>(collector.awaitItem()).value, + ) + sourceOfTruth.liveReaderStarted.await() + + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.clear(key) + } + sourceOfTruth.deleteEchoPublished.await() + assertEquals(1, fetchCalls) + + sourceOfTruth.releaseDelete.complete(Unit) + clear.await() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseDelete.complete(Unit) + store.close() + } + } + + @Test + fun externalAbsentAfterVisibleLocalData_emitsLoadingThenOneMissingAndResetsDedup() = runTest { + val sourceOfTruth = MutableSourceOfTruth(initial = "seed") + val store = store { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + + try { + store.stream(key, Freshness.LocalOnly).test { + assertEquals("seed", assertIs>(awaitItem()).value) + + sourceOfTruth.delete(key) + assertIs(awaitItem()) + assertIs(assertIs(awaitItem()).error) + expectNoEvents() + + sourceOfTruth.write(key, "seed") + assertEquals("seed", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun committedCachedStream_waitsForSourceOfTruthReaderRowBeforeData() = runTest { + val sourceOfTruth = WithheldReaderEchoSourceOfTruth() + val bookkeeper = GateNextSuccessBookkeeper().also { it.gateNextSuccess() } + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcher { + sourceOfTruth.liveReaderStarted.await() + "fetched" + } + } + + try { + store.stream(key).test { + assertIs(awaitItem()) + sourceOfTruth.writeReturned.await() + bookkeeper.successEntered.await() + expectNoEvents() + + bookkeeper.releaseSuccess.complete(Unit) + sourceOfTruth.liveReaderStarted.await() + expectNoEvents() + sourceOfTruth.releaseReaderEcho.complete(Unit) + assertEquals("fetched", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + } finally { + bookkeeper.releaseSuccess.complete(Unit) + sourceOfTruth.releaseReaderEcho.complete(Unit) + store.close() + } + } + + @Test + fun committedMustBeFreshStream_waitsForSourceOfTruthReaderRowBeforeData() = runTest { + val sourceOfTruth = WithheldReaderEchoSourceOfTruth() + val bookkeeper = GateNextSuccessBookkeeper().also { it.gateNextSuccess() } + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcher { "fetched" } + } + + try { + store.stream(key, Freshness.MustBeFresh).test { + assertIs(awaitItem()) + sourceOfTruth.writeReturned.await() + bookkeeper.successEntered.await() + expectNoEvents() + + bookkeeper.releaseSuccess.complete(Unit) + sourceOfTruth.liveReaderStarted.await() + expectNoEvents() + sourceOfTruth.releaseReaderEcho.complete(Unit) + assertEquals("fetched", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + } finally { + bookkeeper.releaseSuccess.complete(Unit) + sourceOfTruth.releaseReaderEcho.complete(Unit) + store.close() + } + } + + @Test + fun newCollectorDuringSettleToWrite_canLaunchOneRedundantFetch() = runTest { + val sourceOfTruth = GatedWriteTailSourceOfTruth() + val secondFetchStarted = CompletableDeferred() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + val call = ++fetchCalls + if (call == 2) secondFetchStarted.complete(Unit) + "fetched-$call" + } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + store.invalidate(key) + + app.cash.turbine.turbineScope { + val first = store.stream(key).testIn(backgroundScope) + assertEquals("seed", assertIs>(first.awaitItem()).value) + sourceOfTruth.writeStarted.await() + + val second = store.stream(key).testIn(backgroundScope) + assertEquals("seed", assertIs>(second.awaitItem()).value) + secondFetchStarted.await() + assertEquals(2, fetchCalls) + + sourceOfTruth.releaseWrite.complete(Unit) + first.cancelAndIgnoreRemainingEvents() + second.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseWrite.complete(Unit) + store.close() + } + } + + @Test + fun preSubscribedCollectors_waitThroughQueuedAbsentThenDeliverWriterCurrentEcho() = runTest { + val sourceOfTruth = QueuedAbsentWriteSourceOfTruth() + val firstFetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val secondFetchStarted = CompletableDeferred() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + val call = ++fetchCalls + when (call) { + 1 -> { + firstFetchStarted.complete(Unit) + releaseFetch.await() + "fresh" + } + + 2 -> { + secondFetchStarted.complete(Unit) + "unexpected" + } + + else -> error("unexpected fetch call $call") + } + } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + store.invalidate(key) + + app.cash.turbine.turbineScope { + val first = store.stream(key).testIn(backgroundScope) + val second = store.stream(key).testIn(backgroundScope) + assertEquals("seed", assertIs>(first.awaitItem()).value) + assertEquals("seed", assertIs>(second.awaitItem()).value) + firstFetchStarted.await() + withContext(Dispatchers.Default) { + sourceOfTruth.awaitLiveReaderSubscription() + } + assertEquals(1, fetchCalls) + + releaseFetch.complete(Unit) + sourceOfTruth.queuedAbsentEmitted.await() + assertIs(first.awaitItem()) + assertIs(second.awaitItem()) + + sourceOfTruth.releaseWriterEcho.complete(Unit) + val firstEcho = assertIs>(first.awaitItem()) + val secondEcho = assertIs>(second.awaitItem()) + assertEquals("fresh", firstEcho.value) + assertEquals("fresh", secondEcho.value) + assertEquals(Origin.FETCHER, firstEcho.origin) + assertEquals(Origin.FETCHER, secondEcho.origin) + assertFalse(firstEcho.refreshing) + assertFalse(secondEcho.refreshing) + testScheduler.runCurrent() + assertFalse(secondFetchStarted.isCompleted) + assertEquals(1, fetchCalls) + first.expectNoEvents() + second.expectNoEvents() + first.cancelAndIgnoreRemainingEvents() + second.cancelAndIgnoreRemainingEvents() + } + } finally { + releaseFetch.complete(Unit) + sourceOfTruth.releaseWriterEcho.complete(Unit) + store.close() + } + } + + @Test + fun externalDeleteAfterWriteReturnsBeforeOutcome_isAuthoritativeAndReplans() = runTest { + val sourceOfTruth = MutableSourceOfTruth(initial = "seed") + val bookkeeper = GateNextSuccessBookkeeper() + val firstFetchStarted = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + val secondFetchStarted = CompletableDeferred() + var fetchCalls = 0 + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcher { + when (++fetchCalls) { + 1 -> { + firstFetchStarted.complete(Unit) + releaseFirstFetch.await() + "candidate" + } + 2 -> { + secondFetchStarted.complete(Unit) + "recovered" + } + + else -> error("unexpected fetch call $fetchCalls") + } + } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + bookkeeper.gateNextSuccess() + + store.stream(key).test { + assertEquals("seed", assertIs>(awaitItem()).value) + firstFetchStarted.await() + releaseFirstFetch.complete(Unit) + withContext(Dispatchers.Default) { + bookkeeper.successEntered.await() + } + assertEquals("candidate", assertIs>(awaitItem()).value) + + sourceOfTruth.delete(key) + assertIs(awaitItem()) + bookkeeper.releaseSuccess.complete(Unit) + + withContext(Dispatchers.Default) { + secondFetchStarted.await() + } + assertEquals("recovered", assertIs>(awaitItem()).value) + assertEquals(2, fetchCalls) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseFirstFetch.complete(Unit) + bookkeeper.releaseSuccess.complete(Unit) + store.close() + } + } + + @Test + fun conformingIntermediateAbsent_convergesFinalWriterWithoutRevalidation() = runTest { + val sourceOfTruth = GatedAppliedWriteSourceOfTruth() + val firstFetchStarted = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + val secondFetchStarted = CompletableDeferred() + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + when (val call = ++fetchCalls) { + 1 -> { + firstFetchStarted.complete(Unit) + releaseFirstFetch.await() + "candidate" + } + + else -> { + secondFetchStarted.complete(Unit) + error("unexpected fetch call $call") + } + } + } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + sourceOfTruth.publishExternal("sync") + assertEquals("sync", assertIs>(observer.awaitItem()).value) + sourceOfTruth.publishExternal("seed") + assertEquals("seed", assertIs>(observer.awaitItem()).value) + + val collector = store.stream(key).testIn(backgroundScope) + assertEquals("seed", assertIs>(collector.awaitItem()).value) + firstFetchStarted.await() + observer.cancelAndIgnoreRemainingEvents() + releaseFirstFetch.complete(Unit) + sourceOfTruth.firstWriteApplied.await() + + sourceOfTruth.publishExternalDelete() + sourceOfTruth.releaseFirstWrite.complete(Unit) + + val candidate = assertIs>(collector.awaitItem()) + assertEquals("candidate", candidate.value) + assertEquals(Origin.FETCHER, candidate.origin) + assertFalse(candidate.isStale) + assertFalse(candidate.refreshing) + assertFalse(secondFetchStarted.isCompleted) + assertEquals(1, fetchCalls) + collector.expectNoEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + releaseFirstFetch.complete(Unit) + sourceOfTruth.releaseFirstWrite.complete(Unit) + store.close() + } + } + + @Test + fun hydratedNullMeta_staleIfErrorWaitsForFailureBeforeFallingBack() = runTest { + val sourceOfTruth = MutableSourceOfTruth(initial = "durable") + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val store = store { + persistence(sourceOfTruth) + fetcher { + fetchStarted.complete(Unit) + releaseFetch.await() + throw IllegalStateException("offline") + } + } + + try { + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key, Freshness.StaleIfError) + } + fetchStarted.await() + assertFalse(read.isCompleted) + + releaseFetch.complete(Unit) + assertEquals("durable", read.await()) + } finally { + releaseFetch.complete(Unit) + store.close() + } + } + + @Test + fun activeMaxAgeCollector_withholdsWriterRowThatExpiresBeforeReaderDelivery() = runTest { + val clock = FakeWallClock(now = 0L) + val sourceOfTruth = GatedSecondWriteSourceOfTruth() + val thirdFetchStarted = CompletableDeferred() + var calls = 0 + val store = storeWith(clock = clock) { + persistence(sourceOfTruth) + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> "v2" + 3 -> { + thirdFetchStarted.complete(Unit) + "v3" + } + + else -> error("unexpected fetch call $calls") + } + } + } + + try { + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + sourceOfTruth.publishExternal("sync") + assertEquals("sync", assertIs>(observer.awaitItem()).value) + sourceOfTruth.publishExternal("seed") + assertEquals("seed", assertIs>(observer.awaitItem()).value) + + assertEquals("v1", store.get(key, Freshness.MustBeFresh)) + val hydrated = assertIs>(observer.awaitItem()) + assertEquals("v1", hydrated.value) + assertEquals(Origin.FETCHER, hydrated.origin) + + val collector = + store.stream(key, Freshness.MaxAge(5.minutes)).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + store.invalidate(key) + assertIs(collector.awaitItem()) + withContext(Dispatchers.Default) { + sourceOfTruth.secondWriteStarted.await() + } + + clock.now = 10.minutes.inWholeMilliseconds + sourceOfTruth.releaseSecondWrite.complete(Unit) + + withContext(Dispatchers.Default) { + thirdFetchStarted.await() + } + assertEquals( + "v3", + assertIs>(collector.awaitItem()).value, + ) + assertEquals(3, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseSecondWrite.complete(Unit) + store.close() + } + } + + @Test + fun mustBeFreshCommittedOutcome_doesNotDeliverNewerExternalNullMetaRow() = runTest { + val sourceOfTruth = FirstWriteWithheldSourceOfTruth() + val bookkeeper = GateNextSuccessBookkeeper() + var fetchCalls = 0 + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcher { if (++fetchCalls == 1) "candidate" else "fresh" } + } + + try { + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + bookkeeper.gateNextSuccess() + + val fresh = store.stream(key, Freshness.MustBeFresh).testIn(backgroundScope) + assertIs(fresh.awaitItem()) + sourceOfTruth.firstWriteReturned.await() + bookkeeper.successEntered.await() + + sourceOfTruth.publishExternal("external") + while (true) { + val item = observer.awaitItem() + if (item is StoreResult.Data && item.value == "external") break + } + bookkeeper.releaseSuccess.complete(Unit) + + assertEquals("fresh", assertIs>(fresh.awaitItem()).value) + assertEquals(2, fetchCalls) + observer.cancelAndIgnoreRemainingEvents() + fresh.cancelAndIgnoreRemainingEvents() + } + } finally { + bookkeeper.releaseSuccess.complete(Unit) + store.close() + } + } + + @Test + fun queuedAbsentBeforeWriterEcho_getReturnsCommitAndPipelineKeepsHonestOrigin() = runTest { + val sourceOfTruth = QueuedAbsentAfterSeedSourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { "candidate" } + } + + try { + assertEquals("seed", store.get(key, Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + sourceOfTruth.publishExternal("sync") + assertEquals("sync", assertIs>(observer.awaitItem()).value) + sourceOfTruth.publishExternal("seed") + assertEquals("seed", assertIs>(observer.awaitItem()).value) + store.invalidate(key) + + val fetched = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key, Freshness.MustBeFresh) + } + sourceOfTruth.queuedAbsentEmitted.await() + assertIs(observer.awaitItem()) + assertIs( + assertIs(observer.awaitItem()).error, + ) + + sourceOfTruth.releaseWriterEcho.complete(Unit) + assertEquals("candidate", fetched.await()) + val echo = assertIs>(observer.awaitItem()) + assertEquals("candidate", echo.value) + assertEquals(Origin.FETCHER, echo.origin) + assertFalse(echo.isStale) + observer.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseWriterEcho.complete(Unit) + store.close() + } + } + + @Test + fun closeDuringSourceOfTruthWrite_cancelsWriteAndDoesNotRecordSuccess() = runTest { + val sourceOfTruth = CancellationWriteSourceOfTruth() + val bookkeeper = InMemoryBookkeeper() + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcher { "value" } + } + + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { store.get(key) } + } + sourceOfTruth.writeStarted.await() + store.close() + + withContext(Dispatchers.Default) { + sourceOfTruth.writeCancelled.await() + } + assertNotNull(read.await().exceptionOrNull()) + assertNull(sourceOfTruth.current) + assertNull(bookkeeper.status(key)) + } + + @Test + fun closeDuringServerDelete_finishesDeleteStateAndBookkeeperTail() = runTest { + val sourceOfTruth = GatedServerDeleteSourceOfTruth() + val bookkeeper = ForgetSignallingBookkeeper() + var calls = 0 + val store = storeWith(bookkeeper = bookkeeper) { + persistence(sourceOfTruth) + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> FetcherResult.Deleted + else -> error("unexpected fetch call $calls") + } + } + } + + assertEquals("v1", store.get(key)) + val deleting = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { store.get(key, Freshness.MustBeFresh) } + } + sourceOfTruth.deleteStarted.await() + store.close() + sourceOfTruth.releaseDelete.complete(Unit) + + withContext(Dispatchers.Default) { + bookkeeper.forgotten.await() + } + assertNotNull(deleting.await().exceptionOrNull()) + assertNull(sourceOfTruth.current) + assertNull(bookkeeper.status(key)) + } + + private suspend fun awaitLocalValue( + store: Store, + expected: String, + ) { + // Preserve Default-dispatch ordering and let the suite-level runTest bound own cancellation. + withContext(Dispatchers.Default) { + store.stream(key, Freshness.LocalOnly).first { result -> + result is StoreResult.Data && result.value == expected + } + } + } + + /** Waits for the already-active observer to deliver [expected], proving its pipeline caught up. */ + private suspend fun ReceiveTurbine>.awaitDataValue(expected: String) { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == expected) return + } + } + + private fun assertExternalSotStale(data: StoreResult.Data) { + assertEquals("external", data.value) + assertEquals(Origin.SOT, data.origin) + assertTrue(data.isStale) + } + + private class GatedFailingServerDeleteSourceOfTruth( + private val failure: Throwable, + ) : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + val deleteStarted = CompletableDeferred() + val releaseDelete = CompletableDeferred() + var deleteCalls: Int = 0 + private set + val current: String? + get() = rows.value + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + deleteCalls += 1 + deleteStarted.complete(Unit) + releaseDelete.await() + throw failure + } + } + + private class FailureTrackingBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + val failureTimes = mutableListOf() + var forgetCalls: Int = 0 + private set + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + failureTimes += atEpochMillis + delegate.recordFailure(key, atEpochMillis) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + forgetCalls += 1 + delegate.forget(key) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class MutableSourceOfTruth( + initial: String? = null, + ) : SingleRowTestSourceOfTruth { + private data class VersionedRow( + val value: String?, + val version: Long, + ) + + private val rows = MutableStateFlow(VersionedRow(initial, version = 0L)) + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + var deleteFailure: Throwable? = null + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return flow { + rows.collect { row -> emit(row.value) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + publish(value) + } + + override suspend fun delete(key: TestKey) { + deleteFailure?.let { throw it } + publish(null) + } + + fun publishExternal(value: String) { + publish(value) + } + + private fun publish(value: String?) { + rows.value = + VersionedRow( + value = value, + version = rows.value.version + 1L, + ) + } + } + + private class ReplayableSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = kotlinx.coroutines.flow.MutableSharedFlow(replay = 1) + private var readerCalls = 0 + private var pendingReplayObservation: CompletableDeferred? = null + val liveReaderStarted = CompletableDeferred() + + init { + rows.tryEmit(null) + } + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return flow { + rows.collect { row -> + emit(row) + pendingReplayObservation?.complete(Unit) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + suspend fun replay(value: String) { + val observed = CompletableDeferred() + check(pendingReplayObservation == null) { "A replay acknowledgement is already pending." } + pendingReplayObservation = observed + rows.emit(value) + observed.await() + if (pendingReplayObservation === observed) pendingReplayObservation = null + } + + suspend fun publishExternal(value: String) { + rows.emit(value) + } + } + + private class GatedHydrationSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("stale") + private var gateFirstReader = true + val readerStarted = CompletableDeferred() + val releaseReader = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + if (!gateFirstReader) return rows + gateFirstReader = false + val snapshot = rows.value + return flow { + readerStarted.complete(Unit) + releaseReader.await() + emit(snapshot) + awaitCancellation() + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class GateNextSuccessBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + private var gateNext = false + val successEntered = CompletableDeferred() + val releaseSuccess = CompletableDeferred() + + fun gateNextSuccess() { + gateNext = true + } + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + if (gateNext) { + gateNext = false + successEntered.complete(Unit) + releaseSuccess.await() + } + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + delegate.recordFailure(key, atEpochMillis) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + delegate.forget(key) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class GraceGateSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + private var readerCalls = 0 + private var gateExternal = false + val liveReaderStarted = CompletableDeferred() + val externalEmissionBlocked = CompletableDeferred() + val releaseExternalEmission = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val call = readerCalls + return flow { + if (call >= 2) liveReaderStarted.complete(Unit) + rows.collect { row -> + if (gateExternal && row == "external") { + externalEmissionBlocked.complete(Unit) + releaseExternalEmission.await() + } + emit(row) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + + fun gateExternalEmission() { + gateExternal = true + } + } + + private class GatedDeleteSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + val deleteStarted = CompletableDeferred() + val releaseDelete = CompletableDeferred() + var deleteCalls: Int = 0 + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + deleteCalls += 1 + deleteStarted.complete(Unit) + releaseDelete.await() + rows.value = null + } + } + + private class BackpressureSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + private var readerCalls = 0 + val deleteStarted = CompletableDeferred() + val liveReaderStarted = CompletableDeferred() + val externalReaderObserved = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return flow { + rows.collect { row -> + emit(row) + if (row == "external") externalReaderObserved.complete(Unit) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + deleteStarted.complete(Unit) + rows.value = null + } + + fun publishExternal(value: String) { + rows.value = value + } + } + + private class WithheldReaderEchoSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + val liveReaderStarted = CompletableDeferred() + val writeReturned = CompletableDeferred() + val releaseReaderEcho = CompletableDeferred() + + override fun reader(key: TestKey): Flow = + flow { + val initial = rows.value + if (initial != null) { + // A long-lived reader may start after the write. Mark it live, but withhold its + // immediate current row behind the same delivery gate as an active-reader echo. + liveReaderStarted.complete(Unit) + releaseReaderEcho.await() + } + emit(initial) + + // A one-shot hydration probe is cancelled by `first()` during the emit above and + // never reaches this collection. A long-lived reader either observes the same + // snapshot as its first StateFlow callback or a write that raced the handoff. + var firstStateFlowEmission = true + rows.collect { row -> + if (firstStateFlowEmission) { + firstStateFlowEmission = false + liveReaderStarted.complete(Unit) + if (row == initial) return@collect + } + releaseReaderEcho.await() + emit(row) + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + writeReturned.complete(Unit) + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class GatedDeleteEchoSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + private var readerCalls = 0 + private val deleteInitiated = CompletableDeferred() + val liveReaderStarted = CompletableDeferred() + val deleteEchoPublished = CompletableDeferred() + val releaseDelete = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return flow { + rows.collect { row -> + emit(row) + if (row == null && deleteInitiated.isCompleted) { + deleteEchoPublished.complete(Unit) + } + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + deleteInitiated.complete(Unit) + rows.value = null + releaseDelete.await() + } + } + + private class GatedWriteTailSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + val writeStarted = CompletableDeferred() + val releaseWrite = CompletableDeferred() + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeStarted.complete(Unit) + releaseWrite.await() + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class QueuedAbsentWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = kotlinx.coroutines.flow.MutableSharedFlow() + private var readerCalls = 0 + val queuedAbsentEmitted = CompletableDeferred() + val releaseWriterEcho = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls == 1) return flow { emit("seed") } + return liveRows + } + + suspend fun awaitLiveReaderSubscription() { + liveRows.subscriptionCount.first { count -> count > 0 } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + liveRows.emit(null) + queuedAbsentEmitted.complete(Unit) + releaseWriterEcho.await() + liveRows.emit(value) + } + + override suspend fun delete(key: TestKey) { + liveRows.emit(null) + } + + suspend fun publishExternal(value: String) { + liveRows.emit(value) + } + } + + private class GatedAppliedWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = kotlinx.coroutines.flow.MutableSharedFlow(replay = 1) + private val pendingDeleteObservation = MutableStateFlow?>(null) + private var readerCalls = 0 + private var writes = 0 + val liveReaderStarted = CompletableDeferred() + val firstWriteApplied = CompletableDeferred() + val releaseFirstWrite = CompletableDeferred() + + init { + rows.tryEmit("seed") + } + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return flow { + rows.collect { row -> + emit(row) + if (row == null) pendingDeleteObservation.value?.complete(Unit) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + writes += 1 + if (writes == 1) { + rows.emit(value) + firstWriteApplied.complete(Unit) + releaseFirstWrite.await() + } + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + suspend fun publishExternalDelete() { + val observed = CompletableDeferred() + check(pendingDeleteObservation.compareAndSet(null, observed)) + rows.emit(null) + observed.await() + pendingDeleteObservation.compareAndSet(observed, null) + } + + suspend fun publishExternal(value: String) { + rows.emit(value) + } + } + + private class GatedSecondWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + private var readerCalls = 0 + private var writes = 0 + val liveReaderStarted = CompletableDeferred() + val secondWriteStarted = CompletableDeferred() + val releaseSecondWrite = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return rows + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + writes += 1 + if (writes == 2) { + secondWriteStarted.complete(Unit) + releaseSecondWrite.await() + } + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + + fun publishExternal(value: String) { + rows.value = value + } + } + + private class QueuedAbsentAfterSeedSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = kotlinx.coroutines.flow.MutableSharedFlow(replay = 1) + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + val queuedAbsentEmitted = CompletableDeferred() + val releaseWriterEcho = CompletableDeferred() + + init { + rows.tryEmit("seed") + } + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return rows + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.emit(null) + queuedAbsentEmitted.complete(Unit) + releaseWriterEcho.await() + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + suspend fun publishExternal(value: String) { + rows.emit(value) + } + } + + private class CancellationWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + val writeStarted = CompletableDeferred() + val writeCancelled = CompletableDeferred() + val current: String? + get() = rows.value + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeStarted.complete(Unit) + try { + awaitCancellation() + } finally { + writeCancelled.complete(Unit) + } + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class GatedServerDeleteSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + val deleteStarted = CompletableDeferred() + val releaseDelete = CompletableDeferred() + val current: String? + get() = rows.value + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + deleteStarted.complete(Unit) + releaseDelete.await() + rows.value = null + } + } + + private class ForgetSignallingBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + val forgotten = CompletableDeferred() + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + delegate.recordFailure(key, atEpochMillis) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + delegate.forget(key) + forgotten.complete(Unit) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class FirstWriteWithheldSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + private var writes = 0 + val firstWriteReturned = CompletableDeferred() + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + writes += 1 + if (writes == 1) { + rows.value = value + firstWriteReturned.complete(Unit) + } else { + rows.value = value + } + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + + fun publishExternal(value: String) { + rows.value = value + } + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthCancellationConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthCancellationConformanceTest.kt new file mode 100644 index 000000000..2ec0e084f --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthCancellationConformanceTest.kt @@ -0,0 +1,545 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.internal.DefaultFreshnessValidator +import org.mobilenativefoundation.store6.core.internal.EngineStoreMeta +import org.mobilenativefoundation.store6.core.internal.FetchDisposition +import org.mobilenativefoundation.store6.core.internal.FetchSlot +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.KeyEngine +import org.mobilenativefoundation.store6.core.internal.KeyId +import org.mobilenativefoundation.store6.core.internal.ResultFetcher +import org.mobilenativefoundation.store6.core.internal.KeyState +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class SourceOfTruthCancellationConformanceTest { + + @Test + fun writeCancellation_revokesTag_cancelsTicket_andMutatesNothing() = runTest { + val key = TestKey("write-cancellation") + val sot = ThrowingWriteSourceOfTruth() + val bookkeeper = InMemoryBookkeeper() + val engine = engine(key, sot, bookkeeper, backgroundScope) { FetcherResult.Success("value") } + + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + sot.writeStarted.await() + val tag = assertNotNull(engine.state.value.attribution) + val ticket = tag.owner + assertEquals(FetchDisposition.Committing::class, ticket.disposition.value::class) + + sot.releaseCancellation.complete(Unit) + val failure = read.await().exceptionOrNull() + + assertIs(failure) + assertTrue(ticket.outcome.isCancelled) + assertEquals(FetchDisposition.Cancelled, ticket.disposition.value) + assertEquals(KeyState.Initial, engine.state.value) + assertNull(sot.current) + assertNull(bookkeeper.status(key)) + + sot.publishExternal("value") + engine.stream(Freshness.LocalOnly).test { + val external = assertIs>(awaitItem()) + assertEquals("value", external.value) + assertEquals(Origin.SOT, external.origin) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun writeCancellation_terminatesOwningStream_andEngineRemainsReusable() = runTest { + val key = TestKey("write-cancellation-stream") + val sot = ThrowingWriteSourceOfTruth() + val bookkeeper = InMemoryBookkeeper() + val engine = engine(key, sot, bookkeeper, backgroundScope) { FetcherResult.Success("value") } + val collector = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.stream(Freshness.CachedOrFetch).collect() } + } + + sot.writeStarted.await() + val ticket = assertNotNull(engine.state.value.attribution).owner + sot.releaseCancellation.complete(Unit) + + val failure = + withContext(Dispatchers.Default) { + collector.await() + }.exceptionOrNull() + assertIs(failure) + assertEquals("write cancelled", failure.message) + assertTrue(ticket.outcome.isCancelled) + assertEquals(FetchDisposition.Cancelled, ticket.disposition.value) + assertEquals(KeyState.Initial, engine.state.value) + assertNull(sot.current) + assertNull(bookkeeper.status(key)) + + sot.publishExternal("external") + engine.stream(Freshness.LocalOnly).test { + val external = assertIs>(awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun clearDeleteCancellation_preservesState() = runTest { + val key = TestKey("clear-delete-cancellation") + val sot = ThrowingDeleteSourceOfTruth(initial = "seed") + val bookkeeper = InMemoryBookkeeper() + val meta = EngineStoreMeta(writtenAtEpochMillis = 10L, etag = "seed") + bookkeeper.recordSuccess(key, meta) + val engine = engine(key, sot, bookkeeper, backgroundScope) { error("fetch must not run") } + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + engine.invalidate() + val stateBefore = engine.state.value + val statusBefore = assertNotNull(bookkeeper.status(key)) + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.clear() } + } + sot.deleteStarted.await() + + sot.releaseCancellation.complete(Unit) + val failure = clear.await().exceptionOrNull() + + assertIs(failure) + assertEquals(stateBefore, engine.state.value) + assertEquals("seed", sot.current) + assertEquals("seed", engine.get(Freshness.LocalOnly)) + assertTrue(bookkeeper.status(key) === statusBefore) + assertEquals(1, sot.deleteCalls) + } + + @Test + fun serverDeleteCancellation_preservesStateAndCancelsTicket() = runTest { + val key = TestKey("server-delete-cancellation") + val sot = ThrowingDeleteSourceOfTruth(initial = "seed") + val bookkeeper = InMemoryBookkeeper() + val meta = EngineStoreMeta(writtenAtEpochMillis = 20L, etag = "seed") + bookkeeper.recordSuccess(key, meta) + val engine = engine(key, sot, bookkeeper, backgroundScope) { FetcherResult.Deleted } + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + engine.invalidate() + val stateBefore = engine.state.value + val statusBefore = assertNotNull(bookkeeper.status(key)) + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + sot.deleteStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + + sot.releaseCancellation.complete(Unit) + val failure = read.await().exceptionOrNull() + + assertIs(failure) + assertTrue(ticket.outcome.isCancelled) + assertEquals(stateBefore, engine.state.value) + assertEquals("seed", sot.current) + assertEquals("seed", engine.get(Freshness.LocalOnly)) + assertTrue(bookkeeper.status(key) === statusBefore) + assertEquals(1, sot.deleteCalls) + } + + @Test + fun serverDeleteCancellation_terminatesDynamicWatcher_andPreservesResident() = runTest { + val key = TestKey("server-delete-cancellation-stream") + val sot = ThrowingDeleteSourceOfTruth(initial = null) + val bookkeeper = InMemoryBookkeeper() + var fetchCalls = 0 + val engine = + engine(key, sot, bookkeeper, backgroundScope) { + when (++fetchCalls) { + 1 -> FetcherResult.Success("seed") + 2 -> FetcherResult.Deleted + else -> error("unexpected fetch call $fetchCalls") + } + } + + assertEquals("seed", engine.get(Freshness.CachedOrFetch)) + val statusBeforeInvalidation = assertNotNull(bookkeeper.status(key)) + val initialData = CompletableDeferred() + val collector = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + engine.stream(Freshness.CachedOrFetch).collect { result -> + if (result is StoreResult.Data && result.value == "seed") { + initialData.complete(Unit) + } + } + } + } + initialData.await() + + engine.invalidate() + val statusAfterInvalidation = assertNotNull(bookkeeper.status(key)) + assertTrue(statusAfterInvalidation.durablyStale) + assertEquals(statusBeforeInvalidation.meta, statusAfterInvalidation.meta) + sot.deleteStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + sot.releaseCancellation.complete(Unit) + + val failure = + withContext(Dispatchers.Default) { + collector.await() + }.exceptionOrNull() + assertIs(failure) + assertEquals("delete cancelled", failure.message) + assertTrue(ticket.outcome.isCancelled) + assertEquals("seed", sot.current) + assertTrue(bookkeeper.status(key) === statusAfterInvalidation) + assertEquals(2, fetchCalls) + + engine.stream(Freshness.LocalOnly).test { + val resident = assertIs>(awaitItem()) + assertEquals("seed", resident.value) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun commitFetch_cancelAfterDurableWrite_completesBookkeepingAndResidenceAtom() = runTest { + val key = TestKey("post-write-cancellation") + val sot = PostWriteReturnSourceOfTruth() + val bookkeeper = SignallingBookkeeper() + val engineJob = SupervisorJob() + val engineScope = CoroutineScope(coroutineContext + engineJob) + val engine = engine(key, sot, bookkeeper, engineScope) { FetcherResult.Success("durable") } + + try { + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + sot.writeApplied.await() + val ticket = assertNotNull(engine.state.value.attribution).owner + + engineJob.cancel(CancellationException("cancel after durable write")) + sot.releaseReturn.complete(Unit) + bookkeeper.successRecorded.await() + runCurrent() + + assertIs(read.await().exceptionOrNull()) + assertEquals("durable", sot.current) + assertNotNull(bookkeeper.status(key)?.meta) + assertIs(engine.state.value.fetch) + assertNull(engine.state.value.attribution) + assertIs(ticket.disposition.value) + assertTrue(ticket.outcome.isCancelled) + } finally { + sot.releaseReturn.complete(Unit) + engineJob.cancel() + } + } + + @Test + fun clear_cancelBeforeWriteLock_performsNoDelete() = runTest { + val key = TestKey("clear-before-write-lock") + val sot = GatedSuccessfulDeleteSourceOfTruth(initial = "seed") + val bookkeeper = InMemoryBookkeeper() + val engine = engine(key, sot, bookkeeper, backgroundScope) { error("fetch must not run") } + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + val first = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + sot.deleteStarted.await() + val second = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + runCurrent() + + second.cancel(CancellationException("cancel before writeLock")) + sot.releaseDelete.complete(Unit) + first.await() + + assertIs(runCatching { second.await() }.exceptionOrNull()) + assertEquals(1, sot.deleteCalls) + assertNull(sot.current) + assertEquals(1L, engine.state.value.clearEpoch) + assertEquals(1L, engine.state.value.readerGen) + } + + @Test + fun commitDeleted_cancelAfterDurableDelete_completesStateAndBookkeepingAtomically() = runTest { + val key = TestKey("post-delete-cancellation") + val sot = PostDeleteReturnSourceOfTruth(initial = "seed") + val bookkeeper = SignallingBookkeeper() + bookkeeper.recordSuccess( + key, + EngineStoreMeta(writtenAtEpochMillis = 30L, etag = "seed"), + ) + val engineJob = SupervisorJob() + val engineScope = CoroutineScope(coroutineContext + engineJob) + val engine = engine(key, sot, bookkeeper, engineScope) { FetcherResult.Deleted } + + try { + assertEquals("seed", engine.get(Freshness.LocalOnly)) + engine.invalidate() + val stateBefore = engine.state.value + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + sot.deleteApplied.await() + val ticket = assertIs(engine.state.value.fetch).ticket + + engineJob.cancel(CancellationException("cancel after durable delete")) + sot.releaseReturn.complete(Unit) + bookkeeper.forgotten.await() + runCurrent() + + assertIs(read.await().exceptionOrNull()) + assertNull(sot.current) + assertNull(bookkeeper.status(key)) + assertIs(engine.state.value.fetch) + assertEquals(stateBefore.clearEpoch + 1L, engine.state.value.clearEpoch) + assertEquals(stateBefore.readerGen + 1L, engine.state.value.readerGen) + assertEquals(stateBefore.staleEpoch, engine.state.value.staleEpoch) + assertNull(engine.state.value.attribution) + assertEquals(FetchDisposition.Deleted, ticket.disposition.value) + assertTrue(ticket.outcome.isCancelled) + } finally { + sot.releaseReturn.complete(Unit) + engineJob.cancel() + } + } + + private fun engine( + key: TestKey, + sot: SourceOfTruth, + bookkeeper: Bookkeeper, + scope: CoroutineScope, + fetcher: suspend (TestKey) -> FetcherResult, + ): KeyEngine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher(fetcher), + sot = sot, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 100L), + engineScope = scope, + ) + + private class ThrowingWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val row = MutableStateFlow(null) + val writeStarted = CompletableDeferred() + val releaseCancellation = CompletableDeferred() + val current: String? + get() = row.value + + override fun reader(key: TestKey): Flow = row + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeStarted.complete(Unit) + releaseCancellation.await() + throw CancellationException("write cancelled") + } + + override suspend fun delete(key: TestKey) { + row.value = null + } + + fun publishExternal(value: String) { + row.value = value + } + } + + private class ThrowingDeleteSourceOfTruth( + initial: String?, + ) : SingleRowTestSourceOfTruth { + private val row = MutableStateFlow(initial) + val deleteStarted = CompletableDeferred() + val releaseCancellation = CompletableDeferred() + var deleteCalls: Int = 0 + private set + val current: String? + get() = row.value + + override fun reader(key: TestKey): Flow = row + + override suspend fun write( + key: TestKey, + value: String, + ) { + row.value = value + } + + override suspend fun delete(key: TestKey) { + deleteCalls++ + deleteStarted.complete(Unit) + releaseCancellation.await() + throw CancellationException("delete cancelled") + } + } + + private class PostWriteReturnSourceOfTruth : SingleRowTestSourceOfTruth { + private val row = MutableStateFlow(null) + val writeApplied = CompletableDeferred() + val releaseReturn = CompletableDeferred() + val current: String? + get() = row.value + + override fun reader(key: TestKey): Flow = row + + override suspend fun write( + key: TestKey, + value: String, + ) { + row.value = value + writeApplied.complete(Unit) + withContext(NonCancellable) { releaseReturn.await() } + } + + override suspend fun delete(key: TestKey) { + row.value = null + } + } + + private class GatedSuccessfulDeleteSourceOfTruth( + initial: String?, + ) : SingleRowTestSourceOfTruth { + private val row = MutableStateFlow(initial) + val deleteStarted = CompletableDeferred() + val releaseDelete = CompletableDeferred() + var deleteCalls: Int = 0 + private set + val current: String? + get() = row.value + + override fun reader(key: TestKey): Flow = row + + override suspend fun write( + key: TestKey, + value: String, + ) { + row.value = value + } + + override suspend fun delete(key: TestKey) { + deleteCalls++ + deleteStarted.complete(Unit) + releaseDelete.await() + row.value = null + } + } + + private class PostDeleteReturnSourceOfTruth( + initial: String?, + ) : SingleRowTestSourceOfTruth { + private val row = MutableStateFlow(initial) + val deleteApplied = CompletableDeferred() + val releaseReturn = CompletableDeferred() + val current: String? + get() = row.value + + override fun reader(key: TestKey): Flow = row + + override suspend fun write( + key: TestKey, + value: String, + ) { + row.value = value + } + + override suspend fun delete(key: TestKey) { + row.value = null + deleteApplied.complete(Unit) + releaseReturn.await() + } + } + + private class SignallingBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + val successRecorded = CompletableDeferred() + val forgotten = CompletableDeferred() + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + delegate.recordSuccess(key, meta) + successRecorded.complete(Unit) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + delegate.recordFailure(key, atEpochMillis) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + delegate.forget(key) + forgotten.complete(Unit) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt new file mode 100644 index 000000000..b7a815779 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthConformanceTest.kt @@ -0,0 +1,497 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.turbineScope +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.internal.SharedFlowSourceOfTruth +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class SourceOfTruthConformanceTest { + + // Values arriving via fetch commit are attributed FETCHER. + @Test + fun originHonesty_fetchCommit_emitsFetcher() = runTest { + var calls = 0 + val key = TestKey("1") + val readerProbe = + ReaderDeliveryProbeSourceOfTruth(InMemorySourceOfTruth()) + val store = + store { + fetcher { calls += 1; "v" } + persistence(readerProbe) + } + try { + turbineScope { + // Loading precedes shared-reader enrollment. Keep a public LocalOnly observer live, + // then close the engine-facing delivery edge with the test-only probe. + val seedReader = + store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + val missing = assertIs(seedReader.awaitItem()) + assertIs(missing.error) + assertFalse(missing.servedStale) + assertEquals(0, calls) + readerProbe.awaitCurrentReaderFirstDelivery(key) + + val collector = store.stream(key).testIn(backgroundScope) + assertIs(collector.awaitItem()) + val data = assertIs>(collector.awaitItem()) + assertEquals(Origin.FETCHER, data.origin) + assertFalse(data.isStale) + assertFalse(data.refreshing) + assertEquals(1, calls) + seedReader.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + store.closeAndSettleForTest() + } + } + + // External data is delivered as SOT/stale before its one active-demand revalidation. + @Test + fun originHonesty_externalSotWrite_emitsSotToActiveStream() = runTest { + var calls = 0 + val revalidationStarted = CompletableDeferred() + val revalidationGate = CompletableDeferred() + val sot = SharedFlowSourceOfTruth() + val store = store { + fetcher { + calls++ + if (calls == 1) { + "fetched" + } else { + revalidationStarted.complete(Unit) + revalidationGate.await() + "revalidated" + } + } + persistence(sot) + } + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("fetched", assertIs>(awaitItem()).value) + + sot.write(TestKey("1"), "external") + + val external = assertIs>(awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + assertTrue(external.isStale) + revalidationStarted.await() + assertEquals(2, calls) + revalidationGate.complete(Unit) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // The memory fast path serves without waiting for the pipeline, stamped MEMORY. + @Test + fun originHonesty_memoryFastPath_reStampsMemory() = runTest { + val store = store { fetcher { "v" } } + assertEquals("v", store.get(TestKey("1"))) + store.stream(TestKey("1")).test { + val first = assertIs>(awaitItem()) + assertEquals(Origin.MEMORY, first.origin) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // The stale-tag interleave: a collector-less commit parks a tag; a later external write must + // not inherit it (value binding). Guards the SoT-read values-never-labeled-FETCHER criterion. + @Test + fun dormantCommit_externalWriteBeforeFirstCollector_attributesSot() = runTest { + val key = TestKey("1") + val sot = SharedFlowSourceOfTruth() + val revalidationGate = CompletableDeferred() + var fetchCalls = 0 + val store = store { + fetcher { + fetchCalls++ + if (fetchCalls == 1) { + "fetched" + } else { + revalidationGate.await() + "revalidated" + } + } + persistence(sot) + } + try { + assertEquals("fetched", store.get(key)) + sot.write(key, "external") + val external = + withContext(Dispatchers.Default) { + store.stream(key) + .filterIsInstance>() + .first { it.value == "external" } + } + assertEquals(Origin.SOT, external.origin) + } finally { + revalidationGate.complete(Unit) + store.close() + } + } + + // The F-1 killer: invalidate with multiple active collectors on the DSL default SoT. + @Test + fun orphanRegression_invalidateWithActiveCollectors_allObserveRefetchedData() = runTest { + var calls = 0 + val firstFetchGate = CompletableDeferred() + val secondFetchStarted = CompletableDeferred() + val secondFetchGate = CompletableDeferred() + val store = store { + fetcher { + val call = ++calls + when (call) { + 1 -> firstFetchGate.await() + 2 -> { + secondFetchStarted.complete(Unit) + secondFetchGate.await() + } + else -> error("unexpected fetch call $call") + } + "v$call" + } + } + try { + turbineScope { + val key = TestKey("1") + val a = store.stream(key).testIn(backgroundScope) + val b = store.stream(key).testIn(backgroundScope) + assertIs(a.awaitItem()) + assertIs(b.awaitItem()) + // Loading is sent before StreamDelivery.start; drain both collectors while fetch 1 + // is blocked so their initial-ticket watchers are parked before the first commit. + runCurrent() + firstFetchGate.complete(Unit) + assertEquals("v1", assertIs>(a.awaitItem()).value) + assertEquals("v1", assertIs>(b.awaitItem()).value) + + store.invalidate(key) + secondFetchStarted.await() + // Fetch 2 is blocked, so draining the test scheduler causally enrolls both active + // collectors on that ticket before it can settle and a late watcher can launch I3. + runCurrent() + assertEquals(2, calls) + secondFetchGate.complete(Unit) + + var aSeen = false + while (!aSeen) { + val item = a.awaitItem() + aSeen = item is StoreResult.Data && item.value == "v2" + } + var bSeen = false + while (!bSeen) { + val item = b.awaitItem() + bSeen = item is StoreResult.Data && item.value == "v2" + } + assertEquals(2, calls) + a.cancelAndIgnoreRemainingEvents() + b.cancelAndIgnoreRemainingEvents() + } + } finally { + firstFetchGate.complete(Unit) + secondFetchGate.complete(Unit) + store.close() + } + } + + // Resubscribe after clear may duplicate, never lose; cleared value never replays. + @Test + fun resubscribeAfterClear_duplicatesNotLosses() = runTest { + var calls = 0 + val store = store { fetcher { calls++; "v$calls" } } + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + + store.clear(TestKey("1")) + + store.stream(TestKey("1")).test { + var sawLoading = false + var sawFresh = false + while (!sawFresh) { + when (val item = awaitItem()) { + is StoreResult.Loading -> sawLoading = true + is StoreResult.Data -> { + assertTrue(item.value != "v1") + if (item.value == "v2") sawFresh = true + } + else -> Unit + } + } + assertTrue(sawLoading) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // Populate, unsubscribe, outlast the grace, clear, then prove stale replay cannot resurrect. + @Test + fun clearWhilePipelineDormant_neverResurrectsClearedValue() = runTest { + var calls = 0 + val store = store { fetcher { calls++; "v$calls" } } + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + withContext(Dispatchers.Default) { delay(400) } + + store.clear(TestKey("1")) + + store.stream(TestKey("1")).test { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + assertEquals("v2", item.value) + break + } + } + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // Null-on-delete liveness through Store: external delete -> absent transition, stream live. + @Test + fun externalSotDelete_activeStreamSeesAbsentTransitionAndStaysLive() = runTest { + var calls = 0 + val thirdFetchStarted = CompletableDeferred() + val thirdFetchGate = CompletableDeferred() + val sot = SharedFlowSourceOfTruth() + val store = store { + fetcher { + val call = ++calls + if (call == 3) { + thirdFetchStarted.complete(Unit) + thirdFetchGate.await() + } + "fetched-$call" + } + persistence(sot) + } + try { + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("fetched-1", assertIs>(awaitItem()).value) + + sot.delete(TestKey("1")) + + assertIs(awaitItem()) + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + assertEquals("fetched-2", item.value) + assertEquals(Origin.FETCHER, item.origin) + assertFalse(item.isStale) + break + } + } + assertEquals(2, calls) + sot.write(TestKey("1"), "rewritten") + // Keep the valid null-meta revalidation from overtaking the liveness probe. + thirdFetchStarted.await() + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == "rewritten") { + assertEquals(Origin.SOT, item.origin) + assertTrue(item.isStale) + break + } + } + assertEquals(3, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + thirdFetchGate.complete(Unit) + store.close() + } + } + + // A pre-populated SoT serves without a fetch under LocalOnly. + @Test + fun localOnly_prePopulatedSot_getServesWithoutFetcher() = runTest { + var calls = 0 + val sot = SharedFlowSourceOfTruth() + sot.write(TestKey("1"), "durable") + val store = store { + fetcher { calls++; "fetched" } + persistence(sot) + } + assertEquals("durable", store.get(TestKey("1"), Freshness.LocalOnly)) + assertEquals(0, calls) + store.close() + } + + // Hydration: unknown provenance serves and triggers exactly one revalidation. + @Test + fun cachedOrFetch_hydratedRow_servesThenRevalidatesExactlyOnce() = runTest { + var calls = 0 + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val sot = SharedFlowSourceOfTruth() + sot.write(TestKey("1"), "durable") + val store = store { + fetcher { + calls++ + fetchStarted.complete(Unit) + releaseFetch.await() + "fetched" + } + persistence(sot) + } + try { + assertEquals("durable", store.get(TestKey("1"))) + fetchStarted.await() + assertEquals(1, calls) + store.stream(TestKey("1")).test { + val hydrated = assertIs>(awaitItem()) + assertEquals("durable", hydrated.value) + assertTrue(hydrated.refreshing) + + releaseFetch.complete(Unit) + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == "fetched") break + } + cancelAndIgnoreRemainingEvents() + } + assertEquals("fetched", store.get(TestKey("1"))) + assertEquals(1, calls) + } finally { + releaseFetch.complete(Unit) + store.close() + } + } + + // Persisted truth participates in startup before its revalidation can overwrite it. + @Test + fun cachedOrFetch_prePopulatedSot_streamServesSotBeforeRevalidation() = runTest { + var calls = 0 + val fetchStarted = CompletableDeferred() + val fetchGate = CompletableDeferred() + val sot = SharedFlowSourceOfTruth() + sot.write(TestKey("1"), "durable") + val store = store { + fetcher { + calls++ + fetchStarted.complete(Unit) + fetchGate.await() + "fetched" + } + persistence(sot) + } + store.stream(TestKey("1")).test { + val durable = assertIs>(awaitItem()) + assertEquals("durable", durable.value) + assertEquals(Origin.SOT, durable.origin) + assertTrue(durable.isStale) + assertTrue(durable.refreshing) + + fetchStarted.await() + fetchGate.complete(Unit) + val fetched = assertIs>(awaitItem()) + assertEquals("fetched", fetched.value) + assertEquals(Origin.FETCHER, fetched.origin) + assertEquals(1, calls) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // I7: a 304 refreshes metadata and emits Revalidated to the revalidating collector. + @Test + fun notModified_refreshesMetaAndEmitsRevalidated() = runTest { + var calls = 0 + val store = store { + fetcherOfResult { + calls++ + if (calls == 1) { + FetcherResult.Success("v1", etag = "e1") + } else { + FetcherResult.NotModified(etag = "e1") + } + } + } + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + + store.invalidate(TestKey("1")) + + var item = awaitItem() + while (item is StoreResult.Data && item.isStale) { + item = awaitItem() + } + assertIs(item) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // I5 enforcement: exercise every writeLock-holding path under concurrency. + @Test + fun lockOrderCanary_concurrentCommitClearStreamAndGet_terminates() = runTest { + var calls = 0 + val store = store { fetcher { calls++; "v$calls" } } + suspend fun getAllowingConcurrentClear() { + try { + store.get(TestKey("1")) + } catch (failure: StoreException) { + assertIs(failure.error) + } + } + withContext(Dispatchers.Default) { + val collector = launch { + store.stream(TestKey("1")).collect { } + } + repeat(10) { + getAllowingConcurrentClear() + store.invalidate(TestKey("1")) + getAllowingConcurrentClear() + store.clear(TestKey("1")) + } + collector.cancel() + } + store.close() + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthFailureConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthFailureConformanceTest.kt new file mode 100644 index 000000000..362727a98 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthFailureConformanceTest.kt @@ -0,0 +1,569 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.turbineScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.DefaultFreshnessValidator +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.KeyEngine +import org.mobilenativefoundation.store6.core.internal.KeyId +import org.mobilenativefoundation.store6.core.internal.ResultFetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class SourceOfTruthFailureConformanceTest { + + @Test + fun readerFailure_activeStreamEmitsTypedPersistenceAndRecovers() = runTest { + val boom = IllegalStateException("reader outage") + val sot = EpisodeSourceOfTruth(initial = "seed", failure = boom) + val engine = localOnlyEngine(sot, backgroundScope) + + engine.stream(Freshness.LocalOnly).test { + assertEquals("seed", assertIs>(awaitItem()).value) + sot.awaitLiveReader() + + sot.failActiveReader(retryFailures = 0) + + val error = assertIs(awaitItem()) + val persistence = assertIs(error.error) + assertMatchingFailure(persistence.cause, boom) + + sot.recoverWith("recovered") + advanceTimeBy(READER_RETRY_MILLIS) + runCurrent() + + assertEquals("recovered", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun readerFailure_initialLocalOnlyDoesNotReportMissing() = runTest { + val boom = IllegalStateException("initial reader outage") + val sot = InitialFailureThenRecoverySourceOfTruth(boom) + val engine = localOnlyEngine(sot, backgroundScope) + + engine.stream(Freshness.LocalOnly).test { + val error = assertIs(awaitItem()) + val persistence = assertIs(error.error) + assertMatchingFailure(persistence.cause, boom) + + advanceTimeBy(READER_RETRY_MILLIS) + runCurrent() + sot.pipelineStarted.await() + expectNoEvents() + + sot.recoverWith("durable") + runCurrent() + + assertEquals("durable", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun readerCompletion_isTypedAndRetriedDefensively() = runTest { + val sot = CompletingSourceOfTruth(initial = "seed") + val engine = localOnlyEngine(sot, backgroundScope) + + engine.stream(Freshness.LocalOnly).test { + assertEquals("seed", assertIs>(awaitItem()).value) + sot.awaitLiveReader() + + sot.completeNormallyWith("recovered") + + val error = assertIs(awaitItem()) + val persistence = assertIs(error.error) + val cause = assertIs(persistence.cause) + assertTrue(cause.message.orEmpty().contains("completed normally")) + + advanceTimeBy(READER_RETRY_MILLIS) + runCurrent() + + assertEquals("recovered", assertIs>(awaitItem()).value) + assertTrue(sot.readerCalls >= 3) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun readerCancellation_cancelsWithoutPersistenceEmission() = runTest { + val sot = CancellingSourceOfTruth(initial = "seed") + val engine = localOnlyEngine(sot, backgroundScope) + + engine.stream(Freshness.LocalOnly).test { + assertEquals("seed", assertIs>(awaitItem()).value) + sot.awaitLiveReader() + val callsBeforeCancellation = sot.readerCalls + + sot.cancelActiveReader() + sot.cancellationThrown.await() + runCurrent() + + expectNoEvents() + assertEquals(callsBeforeCancellation, sot.readerCalls) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun persistentReaderFailure_coalescesEpisode_andStalledCollectorDoesNotBlockRecovery() = + runTest { + val boom = IllegalStateException("persistent reader outage") + val sot = EpisodeSourceOfTruth(initial = "seed", failure = boom) + val key = TestKey("key") + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("unused") }, + sot = sot, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + turbineScope { + val stalledSawSeed = CompletableDeferred() + val stalledOnFirstError = CompletableDeferred() + val releaseStalledCollector = CompletableDeferred() + val stalled = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + engine.stream(Freshness.LocalOnly).buffer(0).collect { result -> + when (result) { + is StoreResult.Data -> { + if (result.value == "seed") stalledSawSeed.complete(Unit) + } + is StoreResult.Error -> { + assertPersistenceFailure(result, boom) + if (stalledOnFirstError.complete(Unit)) { + releaseStalledCollector.await() + } + } + is StoreResult.Loading, + is StoreResult.Revalidated, + -> Unit + } + } + } + val fast = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + stalledSawSeed.await() + assertEquals("seed", assertIs>(fast.awaitItem()).value) + sot.awaitLiveReader() + + val callsAtEpisodeStart = sot.readerCalls + sot.failActiveReader(retryFailures = 3) + + stalledOnFirstError.await() + assertPersistenceFailure(fast.awaitItem(), boom) + + repeat(3) { + advanceTimeBy(READER_RETRY_MILLIS) + runCurrent() + fast.expectNoEvents() + } + assertEquals(callsAtEpisodeStart + 3, sot.readerCalls) + + // The first Error is still blocking the direct zero-buffer collector. Its queued + // recovery must not backpressure the shared retrying reader or the fast peer. + sot.recoverWith("recovered") + advanceTimeBy(READER_RETRY_MILLIS) + runCurrent() + + assertEquals("recovered", assertIs>(fast.awaitItem()).value) + sot.awaitLiveReader() + + sot.failActiveReader(retryFailures = 0) + assertPersistenceFailure(fast.awaitItem(), boom) + + releaseStalledCollector.complete(Unit) + stalled.cancel() + fast.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun externalNullMetaRowServedBeforeFailure_marksErrorServedStale() = runTest { + val boom = IllegalStateException("revalidation failed") + val revalidationStarted = CompletableDeferred() + val releaseRevalidation = CompletableDeferred() + var calls = 0 + val sot = MutableSourceOfTruth() + val store = + store { + persistence(sot) + fetcher { + calls++ + if (calls == 1) { + "seed" + } else { + revalidationStarted.complete(Unit) + releaseRevalidation.await() + throw boom + } + } + } + + try { + store.stream(TestKey("key")).test { + assertIs(awaitItem()) + assertEquals("seed", assertIs>(awaitItem()).value) + runCurrent() + + sot.publishExternal("external") + val external = assertIs>(awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + assertTrue(external.isStale) + assertTrue(external.refreshing) + + revalidationStarted.await() + releaseRevalidation.complete(Unit) + + val error = assertIs(awaitItem()) + assertTrue(error.servedStale) + val fetch = assertIs(error.error) + assertMatchingFailure(fetch.cause, boom) + assertEquals(2, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun activeNullMetaRow_triggersOneRevalidationPerResidenceRevision() = runTest { + val firstFailure = IllegalStateException("first revalidation failed") + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + val thirdStarted = CompletableDeferred() + val releaseThird = CompletableDeferred() + var calls = 0 + val sot = MutableSourceOfTruth() + val store = + store { + persistence(sot) + fetcher { + calls++ + when (calls) { + 1 -> "seed" + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + throw firstFailure + } + 3 -> { + thirdStarted.complete(Unit) + releaseThird.await() + "refreshed" + } + else -> error("unexpected revalidation $calls") + } + } + } + + try { + store.stream(TestKey("key")).test { + assertIs(awaitItem()) + assertEquals("seed", assertIs>(awaitItem()).value) + + sot.publishExternal("external-1") + val firstExternal = assertIs>(awaitItem()) + assertEquals("external-1", firstExternal.value) + assertTrue(firstExternal.isStale) + secondStarted.await() + assertEquals(2, calls) + + releaseSecond.complete(Unit) + val firstError = assertIs(awaitItem()) + assertTrue(firstError.servedStale) + assertMatchingFailure( + assertIs(firstError.error).cause, + firstFailure, + ) + runCurrent() + expectNoEvents() + assertEquals(2, calls) + + sot.publishExternal("external-2") + val secondExternal = assertIs>(awaitItem()) + assertEquals("external-2", secondExternal.value) + assertTrue(secondExternal.isStale) + thirdStarted.await() + assertEquals(3, calls) + + releaseThird.complete(Unit) + assertEquals("refreshed", assertIs>(awaitItem()).value) + runCurrent() + assertEquals(3, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + private fun localOnlyEngine( + sot: SourceOfTruth, + scope: CoroutineScope, + ): KeyEngine { + val key = TestKey("key") + return KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("unused") }, + sot = sot, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = scope, + ) + } + + private fun assertPersistenceFailure( + result: StoreResult, + cause: Throwable, + ) { + val error = assertIs(result) + val persistence = assertIs(error.error) + assertMatchingFailure(persistence.cause, cause) + } + + private fun assertMatchingFailure( + actual: Throwable?, + expected: Throwable, + ) { + val failure = assertIs(actual) + assertEquals(expected.message, failure.message) + } + + private class EpisodeSourceOfTruth( + initial: String?, + private val failure: Throwable, + ) : SingleRowTestSourceOfTruth { + private var row: String? = initial + private var retryFailuresRemaining = 0 + private val activeFailures = Channel(Channel.UNLIMITED) + private val liveReaderStarts = Channel(Channel.UNLIMITED) + + var readerCalls: Int = 0 + private set + + override fun reader(key: TestKey): Flow { + readerCalls++ + val failBeforeRow = retryFailuresRemaining > 0 + if (failBeforeRow) retryFailuresRemaining-- + return flow { + if (failBeforeRow) throw failure + emit(row) + liveReaderStarts.send(Unit) + throw activeFailures.receive() + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + row = value + } + + override suspend fun delete(key: TestKey) { + row = null + } + + suspend fun awaitLiveReader() { + liveReaderStarts.receive() + } + + fun failActiveReader(retryFailures: Int) { + retryFailuresRemaining = retryFailures + check(activeFailures.trySend(failure).isSuccess) + } + + fun recoverWith(value: String) { + row = value + retryFailuresRemaining = 0 + } + } + + private class InitialFailureThenRecoverySourceOfTruth( + private val failure: Throwable, + ) : SingleRowTestSourceOfTruth { + private var readerCalls = 0 + private var recoveredRow: String? = null + private val releaseRecovery = CompletableDeferred() + val pipelineStarted = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls++ + return if (readerCalls == 1) { + flow { throw failure } + } else { + flow { + pipelineStarted.complete(Unit) + releaseRecovery.await() + emit(recoveredRow) + awaitCancellation() + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + recoveredRow = value + } + + override suspend fun delete(key: TestKey) { + recoveredRow = null + } + + fun recoverWith(value: String) { + recoveredRow = value + releaseRecovery.complete(Unit) + } + } + + private class CompletingSourceOfTruth( + initial: String?, + ) : SingleRowTestSourceOfTruth { + private var row: String? = initial + private var completionConsumed = false + private val releaseCompletion = CompletableDeferred() + private val liveReaderStarts = Channel(Channel.UNLIMITED) + + var readerCalls: Int = 0 + private set + + override fun reader(key: TestKey): Flow { + readerCalls++ + return flow { + emit(row) + liveReaderStarts.send(Unit) + if (!completionConsumed) { + releaseCompletion.await() + completionConsumed = true + return@flow + } + awaitCancellation() + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + row = value + } + + override suspend fun delete(key: TestKey) { + row = null + } + + suspend fun awaitLiveReader() { + liveReaderStarts.receive() + } + + fun completeNormallyWith(value: String) { + row = value + releaseCompletion.complete(Unit) + } + } + + private class CancellingSourceOfTruth( + initial: String?, + ) : SingleRowTestSourceOfTruth { + private var row: String? = initial + private val releaseCancellation = CompletableDeferred() + private val liveReaderStarts = Channel(Channel.UNLIMITED) + val cancellationThrown = CompletableDeferred() + + var readerCalls: Int = 0 + private set + + override fun reader(key: TestKey): Flow { + readerCalls++ + return flow { + emit(row) + liveReaderStarts.send(Unit) + releaseCancellation.await() + cancellationThrown.complete(Unit) + throw CancellationException("reader cancelled") + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + row = value + } + + override suspend fun delete(key: TestKey) { + row = null + } + + suspend fun awaitLiveReader() { + liveReaderStarts.receive() + } + + fun cancelActiveReader() { + releaseCancellation.complete(Unit) + } + } + + private class MutableSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 16, + ).also { flow -> check(flow.tryEmit(null)) } + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + suspend fun publishExternal(value: String) { + rows.emit(value) + } + } + + private companion object { + const val READER_RETRY_MILLIS = 100L + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthHydrationRaceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthHydrationRaceTest.kt new file mode 100644 index 000000000..9814883d7 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthHydrationRaceTest.kt @@ -0,0 +1,245 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.testIn +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +@OptIn(ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class SourceOfTruthHydrationRaceTest { + private val key = TestKey("hydration-race") + + @Test + fun getHydrationRacingClear_neverResurrectsAfterClearReturns() = runTest { + val sourceOfTruth = ClearRacingHydrationSourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + + try { + val hydration = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key, Freshness.LocalOnly) + } + sourceOfTruth.readerStarted.await() + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.clear(key) + } + runCurrent() + + sourceOfTruth.releaseReader.complete(Unit) + assertEquals("snapshot", hydration.await()) + clear.await() + + val missing = + assertFailsWith { + store.get(key, Freshness.LocalOnly) + } + assertIs(missing.error) + } finally { + sourceOfTruth.releaseReader.complete(Unit) + store.close() + } + } + + @Test + fun externalAbsentObservedDuringHydration_neverResurrectsSnapshot() = runTest { + val sourceOfTruth = ReactiveHydrationSourceOfTruth() + val store = localOnlyStore(sourceOfTruth) + + try { + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertIs( + assertIs(observer.awaitItem()).error, + ) + sourceOfTruth.sharedReaderStarted.await() + runCurrent() + sourceOfTruth.prepareGatedDirectSnapshot("snapshot") + + val hydration = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { store.get(key, Freshness.LocalOnly) } + } + sourceOfTruth.directHydrationStarted.await() + + sourceOfTruth.publish("intermediate") + assertEquals( + "intermediate", + assertIs>(observer.awaitItem()).value, + ) + sourceOfTruth.publish(null) + var sawAbsent = false + while (!sawAbsent) { + when (val item = observer.awaitItem()) { + is StoreResult.Loading -> Unit + is StoreResult.Error -> { + assertIs(item.error) + sawAbsent = true + } + is StoreResult.Data -> error("Absent transition emitted ${item.value}.") + is StoreResult.Revalidated -> error("005 must not emit Revalidated.") + } + } + runCurrent() + + sourceOfTruth.releaseDirectHydration.complete(Unit) + val failure = assertIs(hydration.await().exceptionOrNull()) + assertIs(failure.error) + observer.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseDirectHydration.complete(Unit) + store.close() + } + } + + @Test + fun get_localOnly_directAbsentButReactiveRowWins_usesLiveResidence() = runTest { + val sourceOfTruth = ReactiveHydrationSourceOfTruth() + val store = localOnlyStore(sourceOfTruth) + + try { + app.cash.turbine.turbineScope { + val observer = store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + assertIs( + assertIs(observer.awaitItem()).error, + ) + sourceOfTruth.sharedReaderStarted.await() + runCurrent() + sourceOfTruth.prepareGatedDirectSnapshot(null) + + val hydration = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key, Freshness.LocalOnly) + } + sourceOfTruth.directHydrationStarted.await() + + sourceOfTruth.publish("live") + assertEquals("live", assertIs>(observer.awaitItem()).value) + runCurrent() + + sourceOfTruth.releaseDirectHydration.complete(Unit) + assertEquals("live", hydration.await()) + observer.cancelAndIgnoreRemainingEvents() + } + } finally { + sourceOfTruth.releaseDirectHydration.complete(Unit) + store.close() + } + } + + private fun localOnlyStore(sourceOfTruth: SourceOfTruth): Store = + store { + persistence(sourceOfTruth) + fetcher { error("fetch must not run") } + } + + private class ClearRacingHydrationSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("snapshot") + private var gateFirstReader = true + val readerStarted = CompletableDeferred() + val releaseReader = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + if (!gateFirstReader) return rows + gateFirstReader = false + val snapshot = rows.value + return flow { + readerStarted.complete(Unit) + releaseReader.await() + emit(snapshot) + awaitCancellation() + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class ReactiveHydrationSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 8, + ).also { rows -> check(rows.tryEmit(null)) } + private var readerCalls = 0 + private var nextDirectSnapshot: String? = null + private var directSnapshotPrepared = false + + val sharedReaderStarted = CompletableDeferred() + var directHydrationStarted = CompletableDeferred() + private set + var releaseDirectHydration = CompletableDeferred() + private set + + fun prepareGatedDirectSnapshot(snapshot: String?) { + check(readerCalls == 2) { "shared reader must be active before direct hydration" } + check(!directSnapshotPrepared) { "a direct hydration is already prepared" } + nextDirectSnapshot = snapshot + directSnapshotPrepared = true + directHydrationStarted = CompletableDeferred() + releaseDirectHydration = CompletableDeferred() + } + + suspend fun publish(value: String?) { + liveRows.emit(value) + } + + override fun reader(key: TestKey): Flow = + when (++readerCalls) { + 1 -> flow { emit(null) } + 2 -> + flow { + sharedReaderStarted.complete(Unit) + emitAll(liveRows) + } + + else -> { + check(directSnapshotPrepared) { "unexpected reader invocation $readerCalls" } + directSnapshotPrepared = false + val snapshot = nextDirectSnapshot + flow { + directHydrationStarted.complete(Unit) + releaseDirectHydration.await() + emit(snapshot) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + publish(value) + } + + override suspend fun delete(key: TestKey) { + publish(null) + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthSubstitutionTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthSubstitutionTest.kt new file mode 100644 index 000000000..13935ab8f --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/SourceOfTruthSubstitutionTest.kt @@ -0,0 +1,188 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.WallClock + +/** Installs an alternate source of truth without changing public Store conformance scenarios. */ +abstract class SourceOfTruthSubstitutionTest { + private var currentReaderProbe: ReaderDeliveryProbeSourceOfTruth<*, *>? = null + + protected open fun installSot(builder: StoreBuilder) { + builder.persistence( + trackedSot(defaultConformanceSourceOfTruth()), + ) + } + + /** Wraps a test SoT so scheduler-sensitive scenarios can await a real downstream delivery. */ + protected fun trackedSot( + delegate: SourceOfTruth, + ): SourceOfTruth = + ReaderDeliveryProbeSourceOfTruth(delegate).also { currentReaderProbe = it } + + /** + * Records the latest started reader collection before a direct key clear. The matching await + * must then observe a later collection's completed engine-facing delivery. + */ + protected suspend fun prepareNextReaderDelivery(key: StoreKey) { + checkNotNull(currentReaderProbe) { + "The substitution must install its SourceOfTruth through trackedSot()." + }.prepareNextReaderDelivery(key) + } + + /** + * Test-fixture acknowledgement completed only after the selected reader value returns from the + * engine-facing emit. The public LocalOnly baseline starts demand; this closes the downstream + * raw-stamping edge before a fetch or destructive mutation is released. + */ + protected open suspend fun awaitCurrentReaderFirstDelivery(key: StoreKey) { + checkNotNull(currentReaderProbe) { + "The substitution must install its SourceOfTruth through trackedSot()." + }.awaitCurrentReaderFirstDelivery(key) + } + + protected fun testStore( + configure: StoreBuilder.() -> Unit, + ): Store = + store { + configure() + installSot(this) + } + + /** Preserves injected test seams while routing the Store through the same substitution hook. */ + internal fun testStoreWith( + clock: WallClock? = null, + bookkeeper: Bookkeeper? = null, + configure: StoreBuilder.() -> Unit, + ): Store = + storeWith(clock = clock, bookkeeper = bookkeeper) { + configure() + installSot(this) + } +} + +/** No-yield acknowledgement decorator; adversarial decorators may remain nested inside it. */ +internal class ReaderDeliveryProbeSourceOfTruth( + private val delegate: SourceOfTruth, +) : SourceOfTruth { + private val deliveries = ConformanceReaderDeliveries() + + override fun reader(key: K): Flow = + flow { + val sequence = deliveries.begin(key) + delegate.reader(key).collect { value -> + emit(value) + deliveries.complete(key, sequence) + } + } + + override suspend fun write( + key: K, + value: V, + ) { + delegate.write(key, value) + } + + override suspend fun delete(key: K) { + delegate.delete(key) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + delegate.deleteNamespace(namespace) + } + + override suspend fun deleteAll() { + delegate.deleteAll() + } + + suspend fun prepareNextReaderDelivery(key: StoreKey) { + deliveries.prepareNext(key) + } + + suspend fun awaitCurrentReaderFirstDelivery(key: StoreKey) { + // Callers own cancellation through their suite-level runTest bound. + withContext(Dispatchers.Default) { + deliveries.awaitCurrent(key) + } + } +} + +private class ConformanceReaderDeliveries { + private val lock = Mutex() + private val current = HashMap() + private val preparedFloors = HashMap() + + suspend fun begin(key: StoreKey): Long = + lock.withLock { + val deliveries = deliveriesFor(key) + deliveries.startedSequence += 1L + deliveries.startedSequence + } + + suspend fun complete( + key: StoreKey, + sequence: Long, + ) { + lock.withLock { + val deliveries = deliveriesFor(key) + if (sequence > deliveries.completedSequence.value) { + deliveries.completedSequence.value = sequence + } + } + } + + suspend fun prepareNext(key: StoreKey) { + lock.withLock { + val identity = ConformanceKey.from(key) + preparedFloors[identity] = deliveriesFor(identity).startedSequence + } + } + + suspend fun awaitCurrent(key: StoreKey) { + val (completedSequence, floor) = + lock.withLock { + val identity = ConformanceKey.from(key) + deliveriesFor(identity).completedSequence to + (preparedFloors.remove(identity) ?: 0L) + } + completedSequence.first { sequence -> sequence > floor } + } + + private fun deliveriesFor(key: StoreKey): ConformanceReaderDeliveryState = + deliveriesFor(ConformanceKey.from(key)) + + private fun deliveriesFor(key: ConformanceKey): ConformanceReaderDeliveryState = + current.getOrPut(key) { ConformanceReaderDeliveryState() } +} + +private class ConformanceReaderDeliveryState( + var startedSequence: Long = 0L, + val completedSequence: MutableStateFlow = MutableStateFlow(0L), +) + +internal data class ConformanceKey( + val namespace: String, + val canonicalId: String, +) { + companion object { + fun from(key: StoreKey): ConformanceKey = + ConformanceKey( + namespace = key.namespace.value, + canonicalId = key.canonicalId(), + ) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBackpressureConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBackpressureConformanceTest.kt new file mode 100644 index 000000000..295e639e0 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBackpressureConformanceTest.kt @@ -0,0 +1,170 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class StoreBackpressureConformanceTest { + @Test + fun slowCollector_doesNotBlockFastCollector_orEngine_andIsBoundedPerCycle() = + runTest(timeout = 60.seconds) { + val key = TestKey("backpressure") + val slowGate = CompletableDeferred() + val slowParked = CompletableDeferred() + val slowLatestSerial = MutableStateFlow(0) + val fastLatestSerial = MutableStateFlow(0) + var slowDeliveries = 0 + val fetchSerial = MutableStateFlow(0) + val store = + store { + fetcher { + val next = fetchSerial.value + 1 + fetchSerial.value = next + "v$next" + } + } + val slowCollector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + store.stream(key).collect { result -> + result.throwIfError() + slowDeliveries += 1 + slowParked.complete(Unit) + slowGate.await() + if (result is StoreResult.Data && !result.isStale && !result.refreshing) { + slowLatestSerial.value = result.value.removePrefix("v").toInt() + } + } + } + + var fastCollector = backgroundScope.launch { } + try { + slowParked.await() + assertTrue(!slowGate.isCompleted, "slow collector must be parked before cycles") + fastCollector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + store.stream(key).collect { result -> + result.throwIfError() + if (result is StoreResult.Data && !result.isStale && !result.refreshing) { + fastLatestSerial.value = result.value.removePrefix("v").toInt() + } + } + } + awaitUntil { fastLatestSerial.value >= 1 } + + repeat(CYCLES) { + val before = fastLatestSerial.value + store.invalidate(key) + awaitUntil { fastLatestSerial.value > before } + } + awaitUntil { fastLatestSerial.value == fetchSerial.value } + val finalSerial = fetchSerial.value + val finalValue = "v$finalSerial" + assertEquals(finalSerial, fastLatestSerial.value) + assertEquals(finalValue, store.get(key, Freshness.LocalOnly)) + + slowGate.complete(Unit) + awaitUntil { slowLatestSerial.value == finalSerial } + slowCollector.cancelAndJoin() + assertTrue( + slowDeliveries <= MAX_BOUNDED_DELIVERIES, + // The operator unit test pins its internal pending queue to <= 4. This public + // engine-level bound includes the lifecycle deliveries around each cycle. + "slow collector received $slowDeliveries items for $CYCLES cycles", + ) + assertEquals(finalSerial, slowLatestSerial.value) + } finally { + slowGate.complete(Unit) + slowCollector.cancelAndJoin() + fastCollector.cancelAndJoin() + store.close() + } + } + + @Test + fun everyCollector_eventuallyObservesLatestRow() = runTest(timeout = 60.seconds) { + val key = TestKey("eventual") + val delayedGate = CompletableDeferred() + val delayedParked = CompletableDeferred() + val delayedLatest = MutableStateFlow(null) + val fastLatest = MutableStateFlow(null) + var serial = 0 + val fetchFinal = MutableStateFlow(false) + val store = store { + fetcher { if (fetchFinal.value) "final" else "v${++serial}" } + } + val finalValue = "final" + val delayedCollector = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.stream(key) + .onEach { result -> + result.throwIfError() + delayedParked.complete(Unit) + delayedGate.await() + } + .filterIsInstance>() + .map { it.value } + .onEach { delayedLatest.value = it } + .first { it == finalValue } + } + + val fastCollector = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.stream(key) + .onEach { result -> result.throwIfError() } + .filterIsInstance>() + .map { it.value } + .onEach { fastLatest.value = it } + .first { it == finalValue } + } + + try { + delayedParked.await() + assertTrue(!delayedGate.isCompleted, "delayed collector must be gated before cycles") + awaitUntil { fastLatest.value != null } + repeat(EVENTUAL_CYCLES) { + val before = fastLatest.value + store.invalidate(key) + awaitUntil { fastLatest.value != before } + } + fetchFinal.value = true + store.invalidate(key) + + awaitUntil(timeout = 5.seconds) { fastLatest.value == finalValue } + assertEquals(finalValue, fastCollector.await()) + assertEquals(finalValue, store.get(key, Freshness.LocalOnly)) + delayedGate.complete(Unit) + awaitUntil(timeout = 5.seconds) { delayedLatest.value == finalValue } + assertEquals(finalValue, delayedCollector.await()) + } finally { + delayedGate.complete(Unit) + delayedCollector.cancelAndJoin() + fastCollector.cancelAndJoin() + store.close() + } + } + + private fun StoreResult<*>.throwIfError() { + if (this is StoreResult.Error) { + throw AssertionError("unexpected Store error: $error") + } + } + + private companion object { + const val CYCLES = 25 + const val EVENTUAL_CYCLES = 10 + const val MAX_BOUNDED_DELIVERIES = 12 + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBuilderPersistenceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBuilderPersistenceTest.kt new file mode 100644 index 000000000..d2806e801 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreBuilderPersistenceTest.kt @@ -0,0 +1,48 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalStoreApi::class) +class StoreBuilderPersistenceTest { + @Test + fun persistenceRegistration_wiresFetchedWritesIntoTheSelectedSourceOfTruth() = runTest { + val sourceOfTruth = InMemorySourceOfTruth() + val store = store { + persistence(sourceOfTruth) + fetcher { "fetched" } + } + + assertEquals("fetched", store.get(TestKey("key"))) + sourceOfTruth.reader(TestKey("key")).test { + assertEquals("fetched", awaitItem()) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun prepopulatedPersistence_localOnlyGetHydratesWithoutFetching() = runTest { + val sourceOfTruth = InMemorySourceOfTruth() + val key = TestKey("key") + sourceOfTruth.write(key, "durable") + var fetchCalls = 0 + val store = store { + persistence(sourceOfTruth) + fetcher { + fetchCalls += 1 + "network" + } + } + + try { + assertEquals("durable", store.get(key, Freshness.LocalOnly)) + assertEquals(0, fetchCalls) + } finally { + store.close() + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreCloseLifecycleTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreCloseLifecycleTest.kt new file mode 100644 index 000000000..dddd6d41f --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreCloseLifecycleTest.kt @@ -0,0 +1,119 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.internal.RealStore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.seconds + +class StoreCloseLifecycleTest { + @Test + fun close_cancelsCollectorsAndFetches_releasesRegistry_leakChecked() = + runTest(timeout = 60.seconds) { + val key = TestKey("close-active-work") + val fetchGate = CompletableDeferred() + val fetchStarted = CompletableDeferred() + val firstFrame = CompletableDeferred() + val store = + store { + fetcher { + fetchStarted.complete(Unit) + fetchGate.await() + "value" + } + } as RealStore + val waiter = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { store.get(key, Freshness.MustBeFresh) } + } + var collector: Deferred>? = null + + try { + fetchStarted.await() + val activeCollector = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + store.stream(key).collect { + firstFrame.complete(Unit) + } + } + } + collector = activeCollector + firstFrame.await() + + store.close() + + val collectorFailure = + assertIs(activeCollector.await().exceptionOrNull()) + assertEquals("Store is closed.", collectorFailure.message) + val waiterFailure = + assertIs(waiter.await().exceptionOrNull()) + assertEquals("Store is closed.", waiterFailure.message) + store.awaitTerminationForTest() + assertEquals(0, store.residentEngineCountForTest()) + } finally { + withContext(NonCancellable) { + fetchGate.complete(Unit) + waiter.cancelAndJoin() + collector?.cancelAndJoin() + store.close() + } + } + } + + @Test + fun postClose_everyOperationFailsFast_withExactMessage() = runTest(timeout = 60.seconds) { + val key = TestKey("post-close") + val namespace = StoreNamespace("test") + val store = store { fetcher { "value" } } + val preCloseStream = store.stream(key) + + store.close() + store.close() + + assertStoreClosed { store.get(key) } + assertStoreClosed { store.stream(key) } + assertStoreClosed { preCloseStream.collect() } + assertStoreClosed { store.invalidate(key) } + assertStoreClosed { store.invalidateNamespace(namespace) } + assertStoreClosed { store.invalidateAll() } + assertStoreClosed { store.clear(key) } + assertStoreClosed { store.clearNamespace(namespace) } + assertStoreClosed { store.clearAll() } + } + + @Test + fun repeatedOpenCloseCycles_leaveNoResidentState() = runTest(timeout = 60.seconds) { + repeat(50) { cycle -> + val key = TestKey("cycle-$cycle") + val store = + store { + fetcher { "value-$cycle" } + } as RealStore + + try { + assertEquals("value-$cycle", store.get(key)) + } finally { + store.close() + store.awaitTerminationForTest() + } + assertEquals(0, store.residentEngineCountForTest()) + } + } + + private suspend fun assertStoreClosed(operation: suspend () -> Unit) { + val failure = assertFailsWith { operation() } + assertEquals("Store is closed.", failure.message) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConfigSeamsTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConfigSeamsTest.kt new file mode 100644 index 000000000..f7ed6c8a5 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConfigSeamsTest.kt @@ -0,0 +1,122 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.seam.FetchPlan +import org.mobilenativefoundation.store6.core.seam.FreshnessContext +import org.mobilenativefoundation.store6.core.seam.FreshnessValidator +import org.mobilenativefoundation.store6.core.seam.WallClock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class StoreConfigSeamsTest { + private class FixedWallClock(var now: Long) : WallClock { + override fun nowEpochMillis(): Long = now + } + + @Test + fun wallClock_drivesMaxAgePlanning() = runTest { + var fetches = 0 + val clock = FixedWallClock(now = 1_000L) + val store = + store { + fetcher { + fetches++ + "v$fetches" + } + wallClock(clock) + } + + assertEquals("v1", store.get(TestKey("1"), Freshness.MaxAge(5.seconds))) + assertEquals("v1", store.get(TestKey("1"), Freshness.MaxAge(5.seconds))) + assertEquals(1, fetches) + clock.now = 7_000L + assertEquals("v2", store.get(TestKey("1"), Freshness.MaxAge(5.seconds))) + assertEquals(2, fetches) + store.close() + } + + @Test + fun freshnessValidator_alwaysSkip_neverInvokesFetcher() = runTest { + var fetches = 0 + val store = + store { + fetcher { + fetches++ + "v" + } + freshnessValidator( + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan = FetchPlan.Skip + }, + ) + } + + val failure = assertFailsWith { store.get(TestKey("1")) } + assertIs(failure.error) + assertEquals(0, fetches) + store.close() + } + + @Test + fun freshnessValidator_alwaysFetch_refetchesEveryGet() = runTest { + var fetches = 0 + val store = + store { + fetcher { + fetches++ + "v$fetches" + } + freshnessValidator( + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan = + FetchPlan.Fetch(servesResidentWhileFetching = false) + }, + ) + } + + assertEquals("v1", store.get(TestKey("1"))) + assertEquals("v2", store.get(TestKey("1"))) + assertEquals(2, fetches) + store.close() + } + + @Test + fun bookkeeper_sharedAcrossRestart_preservesDurableStaleness() = runTest { + var fetches = 0 + val sharedBookkeeper = InMemoryBookkeeper() + val sharedSot = InMemorySourceOfTruth() + val first = + store { + fetcher { + fetches++ + "v$fetches" + } + persistence(sharedSot) + bookkeeper(sharedBookkeeper) + } + + assertEquals("v1", first.get(TestKey("1"))) + first.invalidate(TestKey("1")) + first.close() + + val second = + store { + fetcher { + fetches++ + "v$fetches" + } + persistence(sharedSot) + bookkeeper(sharedBookkeeper) + } + + assertEquals("v2", second.get(TestKey("1"), Freshness.MaxAge(5.seconds))) + assertEquals(2, fetches) + second.close() + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceSubstitutionRuns.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceSubstitutionRuns.kt new file mode 100644 index 000000000..47d82ff52 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceSubstitutionRuns.kt @@ -0,0 +1,60 @@ +package org.mobilenativefoundation.store6.core + +import org.mobilenativefoundation.store6.core.internal.SharedFlowSourceOfTruth +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +@OptIn(ExperimentalStoreApi::class) +class StoreConformanceAgainstSharedFlowSotTest : StoreConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence(SharedFlowSourceOfTruth()) + } +} + +@OptIn(ExperimentalStoreApi::class) +class StoreInvalidationConformanceAgainstSharedFlowSotTest : StoreInvalidationConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence(trackedSot(SharedFlowSourceOfTruth())) + } +} + +@OptIn(ExperimentalStoreApi::class) +class EmissionSequenceConformanceAgainstSharedFlowSotTest : EmissionSequenceConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence(trackedSot(SharedFlowSourceOfTruth())) + } +} + +@OptIn(ExperimentalStoreApi::class) +class SingleFlightConformanceAgainstSharedFlowSotTest : SingleFlightConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence(SharedFlowSourceOfTruth()) + } +} + +@OptIn(ExperimentalStoreApi::class) +class FreshnessPolicyConformanceAgainstSharedFlowSotTest : FreshnessPolicyConformanceTest() { + override fun installSot(builder: StoreBuilder) { + builder.persistence(trackedSot(SharedFlowSourceOfTruth())) + } +} + +@OptIn(ExperimentalStoreApi::class) +class StoreRevalidationConformanceTestAgainstSharedFlowSot : StoreRevalidationConformance() { + override fun installSot(builder: StoreBuilder) { + builder.persistence(SharedFlowSourceOfTruth()) + } +} + +@OptIn(ExperimentalStoreApi::class) +class StoreDurableMaintenanceConformanceAgainstSharedFlowSotTest : + StoreDurableMaintenanceConformance() { + override fun createSourceOfTruth(): SourceOfTruth = + SharedFlowSourceOfTruth() +} + +@OptIn(ExperimentalStoreApi::class) +class StoreInvalidationStressAgainstSharedFlowSotTest : StoreInvalidationStressConformance() { + override fun installSot(builder: StoreBuilder) { + builder.persistence(SharedFlowSourceOfTruth()) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt new file mode 100644 index 000000000..7cc8a555d --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreConformanceTest.kt @@ -0,0 +1,277 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.turbineScope +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.seconds + +open class StoreConformanceTest : SourceOfTruthSubstitutionTest() { + + // (a) the cold-stream acceptance test: Loading then Data(origin=FETCHER) + @Test + fun coldStream_noCachedValue_emitsLoadingThenDataFromFetcher() = runTest { + val store = testStore { fetcher { "value-for-${it.canonicalId()}" } } + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + val data = assertIs>(awaitItem()) + assertEquals("value-for-1", data.value) + assertEquals(Origin.FETCHER, data.origin) + expectNoEvents() // live, not completed + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // (b1) fetcher throws -> stream emits Error and stays live (the stream never throws) + @Test + fun fetcherThrows_streamEmitsErrorAndStaysLive() = runTest { + val store = testStore { fetcher { throw IllegalStateException("boom") } } + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + val error = assertIs(awaitItem()) + assertIs(error.error) + expectNoEvents() // Error did not terminate the flow + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + // (b2) fetcher throws -> get throws StoreException carrying StoreError.Fetch + @Test + fun fetcherThrows_getThrowsStoreException() = runTest { + val store = testStore { fetcher { throw IllegalStateException("boom") } } + val ex = assertFailsWith { store.get(TestKey("1")) } + assertIs(ex.error) + store.close() + } + + // (c) single-flight smoke: two concurrent collectors, one fetcher invocation + @Test + fun twoConcurrentCollectors_singleFetcherInvocation() = runTest { + var calls = 0 + val gate = CompletableDeferred() + val store = testStore { + fetcher { + calls++ + gate.await() + "v" + } + } + turbineScope { + val a = store.stream(TestKey("1")).testIn(backgroundScope) + val b = store.stream(TestKey("1")).testIn(backgroundScope) + assertIs(a.awaitItem()) + assertIs(b.awaitItem()) // both subscribed before the fetch resolves + gate.complete(Unit) + assertEquals("v", assertIs>(a.awaitItem()).value) + assertEquals("v", assertIs>(b.awaitItem()).value) + assertEquals(1, calls) + } + store.close() + } + + // (d) a resident value is served without a refetch + @Test + fun getAfterStreamCommitted_servesResidentValueWithoutRefetch() = runTest { + var calls = 0 + val store = testStore { + fetcher { + calls++ + "v$calls" + } + } + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertIs>(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + assertEquals("v1", store.get(TestKey("1"))) // resident value served + assertEquals(1, calls) // no second fetch + store.close() + } + + // (e) pins replay semantics: a late collector gets Data immediately, never a spurious Loading + @Test + fun lateCollectorAfterData_receivesDataImmediately() = runTest { + val store = testStore { fetcher { "v" } } + assertEquals("v", store.get(TestKey("1"))) // commits residence + store.stream(TestKey("1")).test { + val first = assertIs>(awaitItem()) // no Loading first + assertEquals("v", first.value) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun closeDuringFetch_getWaiterTerminatesPromptly() = runTest { + val started = CompletableDeferred() + val gate = CompletableDeferred() + val store = testStore { + fetcher { + started.complete(Unit) + gate.await() + "v" + } + } + val waiter = backgroundScope.async { runCatching { store.get(TestKey("1")) } } + started.await() + + store.close() + + val failure = + withContext(Dispatchers.Default) { + waiter.await() + }.exceptionOrNull() + assertIs(failure) + } + + @Test + fun closeDuringFetch_streamCollectorTerminatesPromptly() = runTest { + val started = CompletableDeferred() + val gate = CompletableDeferred() + val store = testStore { + fetcher { + started.complete(Unit) + gate.await() + "v" + } + } + val collector = backgroundScope.async { + runCatching { store.stream(TestKey("1")).collect() } + } + started.await() + + store.close() + + val failure = + withContext(Dispatchers.Default) { + collector.await() + }.exceptionOrNull() + assertIs(failure) + } + + @Test + fun getAfterClose_failsFastWithDeterministicException() = runTest { + val store = testStore { fetcher { "v" } } + store.close() + + val failure = assertFailsWith { + withTimeout(1_000) { store.get(TestKey("1")) } + } + + assertEquals("Store is closed.", failure.message) + } + + @Test + fun streamAfterClose_failsFastWithDeterministicException() = runTest { + val store = testStore { fetcher { "v" } } + store.close() + + val failure = assertFailsWith { + store.stream(TestKey("1")) + } + + assertEquals("Store is closed.", failure.message) + } + + @Test + fun streamCreatedBeforeClose_failsFastWhenCollectedAfterClose() = runTest { + val store = testStore { fetcher { "v" } } + val stream = store.stream(TestKey("1")) + store.close() + + val failure = assertFailsWith { + withTimeout(1_000) { stream.collect() } + } + + assertEquals("Store is closed.", failure.message) + } + + @Test + fun nonCooperativeFetcher_closeTerminatesGetBeforeFetcherReleases() = runTest { + val started = CompletableDeferred() + val release = CompletableDeferred() + val store = testStore { + fetcher { + started.complete(Unit) + withContext(NonCancellable) { release.await() } + "v" + } + } + val waiter = backgroundScope.async { runCatching { store.get(TestKey("1")) } } + + try { + started.await() + store.close() + + val failure = + withContext(Dispatchers.Default) { + waiter.await() + }.exceptionOrNull() + assertFalse(release.isCompleted) + assertIs(failure) + } finally { + release.complete(Unit) + store.close() + } + } + + @Test + fun nonCooperativeFetcher_closeTerminatesStreamBeforeFetcherReleases() = runTest { + val started = CompletableDeferred() + val release = CompletableDeferred() + val store = testStore { + fetcher { + started.complete(Unit) + withContext(NonCancellable) { release.await() } + "v" + } + } + val collector = backgroundScope.async { + runCatching { store.stream(TestKey("1")).collect() } + } + + try { + started.await() + store.close() + + val failure = + withContext(Dispatchers.Default) { + collector.await() + }.exceptionOrNull() + assertFalse(release.isCompleted) + assertIs(failure) + } finally { + release.complete(Unit) + store.close() + } + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDefaultsPinTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDefaultsPinTest.kt new file mode 100644 index 000000000..575a78f02 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDefaultsPinTest.kt @@ -0,0 +1,144 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.time.Duration.Companion.seconds + +/** + * Pins the two zero-config defaults the published Important Defaults page states but that no other + * conformance test names as *the default*: the default [Freshness] and the absence of fetcher + * retries. Both lines are public documentation, so both get a test that fails when the + * documentation stops being true. + */ +@OptIn(ExperimentalStoreApi::class) +class StoreDefaultsPinTest { + /** + * The default [Freshness] is [Freshness.CachedOrFetch]. Two observations distinguish it from + * the alternatives: an absent key fetches (so the default is not [Freshness.LocalOnly]), and a + * resident fresh value is served without a second fetch (so it is not + * [Freshness.MustBeFresh]). + */ + @Test + fun defaultFreshness_isCachedOrFetch_zeroConfig() = + runTest(timeout = 60.seconds) { + val fetcher = CountingFetcher() + val store = + store { + fetcher(fetcher::fetch) + } + + try { + val key = TestKey("default-freshness") + + assertEquals("v1:default-freshness", store.get(key)) + assertEquals(1, fetcher.count, "an absent key must fetch: the default is not LocalOnly") + + assertEquals("v1:default-freshness", store.get(key)) + assertEquals( + 1, + fetcher.count, + "a resident fresh value must be served without a second fetch: " + + "the default is not MustBeFresh", + ) + } finally { + store.close() + } + } + + /** + * The engine never retries your fetcher. One demand cycle invokes it exactly once, a failure + * schedules no background retry and no backoff, and a later call is a new demand rather than a + * continuation of the failed one. + * + * The quiet windows below run on [Dispatchers.Default] in real time on purpose: the engine's + * own scope is `Dispatchers.Default` ([RealStore]), so it never observes `runTest`'s virtual + * clock and a virtual-time advance would prove nothing. [NO_RETRY_WINDOW_MILLIS] is an order of + * magnitude above the engine's internal fixed-delay scale, so a retry with backoff would have + * fired inside it. + */ + @Test + fun fetcherFailure_isNotRetried_zeroConfig() = + runTest(timeout = 60.seconds) { + val fetcher = AlwaysFailingFetcher() + val store = + store { + fetcher(fetcher::fetch) + } + + try { + val key = TestKey("no-retry") + + // A terminalizing demand cycle: one call, one invocation. + assertFailsWith { store.get(key, Freshness.MustBeFresh) } + assertEquals(1, fetcher.count, "one demand cycle invokes the fetcher exactly once") + + withContext(Dispatchers.Default) { delay(NO_RETRY_WINDOW_MILLIS) } + assertEquals(1, fetcher.count, "a failed fetch schedules no background retry") + + // A second call is new demand, not a continuation of the failed one. + assertFailsWith { store.get(key, Freshness.MustBeFresh) } + assertEquals(2, fetcher.count, "a second call is a new demand, not a retry") + + // The non-terminalizing path: a stream collector survives a fetch failure and stays + // live. This is where a background retry could hide, so it gets its own window. + val streamKey = TestKey("no-retry-stream") + val collector = + launch(Dispatchers.Default) { + store.stream(streamKey).collect { } + } + try { + withContext(Dispatchers.Default) { + while (fetcher.count < 3) { + delay(POLL_MILLIS) + } + delay(NO_RETRY_WINDOW_MILLIS) + } + assertEquals( + 3, + fetcher.count, + "a live collector whose fetch failed triggers no background retry either", + ) + } finally { + collector.cancelAndJoin() + } + } finally { + store.close() + } + } + + private class CountingFetcher { + var count: Int = 0 + private set + + fun fetch(key: TestKey): String { + count++ + return "v$count:${key.canonicalId()}" + } + } + + private class AlwaysFailingFetcher { + var count: Int = 0 + private set + + fun fetch(key: TestKey): String { + count++ + throw IllegalStateException("fetch failed for ${key.canonicalId()}") + } + } + + private companion object { + /** + * A real-time quiet window, an order of magnitude above the engine's internal fixed-delay + * scale (`READER_RETRY_DELAY_MILLIS = 100L`), so any retry-with-backoff would fire inside it. + */ + const val NO_RETRY_WINDOW_MILLIS = 1_000L + const val POLL_MILLIS = 10L + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceConformanceTest.kt new file mode 100644 index 000000000..39fed9a51 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceConformanceTest.kt @@ -0,0 +1,147 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.hours + +/** + * Simulates restart by constructing a fresh Store around the same Bookkeeper and SourceOfTruth. + * This proves store-instance-independent durable facts, not on-disk durability, which the + * persistence adapter modules cover. + */ +@OptIn(ExperimentalStoreApi::class) +abstract class StoreDurableMaintenanceConformance { + protected abstract fun createSourceOfTruth(): SourceOfTruth + + @Test + fun invalidate_markIsObservedByFreshStoreUsingSharedCollaborators() = runTest { + var calls = 0 + val sharedBookkeeper = InMemoryBookkeeper() + val sharedSot = createSourceOfTruth() + val clock = FakeWallClock(now = 100L) + fun buildStore(): Store = + restartedStore(sharedBookkeeper, sharedSot, clock) { "v${++calls}" } + + val first = buildStore() + try { + assertEquals("v1", first.get(TestKey("1"))) + first.invalidate(TestKey("1")) + } finally { + first.close() + } + + val second = buildStore() + try { + assertEquals("v2", second.get(TestKey("1"), Freshness.MaxAge(1.hours))) + assertEquals(2, calls) + } finally { + second.close() + } + } + + @Test + fun invalidateNamespace_watermarkIsObservedForKeyUnseenByFreshStore() = runTest { + var calls = 0 + val sharedBookkeeper = InMemoryBookkeeper() + val sharedSot = createSourceOfTruth() + val clock = FakeWallClock(now = 100L) + fun buildStore(): Store = + restartedStore(sharedBookkeeper, sharedSot, clock) { "v${++calls}" } + val keyA1 = NamespacedTestKey("a", "1") + val keyA2 = NamespacedTestKey("a", "2") + + val first = buildStore() + try { + assertEquals("v1", first.get(keyA1)) + assertEquals("v2", first.get(keyA2)) + first.invalidateNamespace(StoreNamespace("a")) + } finally { + first.close() + } + + val second = buildStore() + try { + assertEquals("v3", second.get(keyA2, Freshness.MaxAge(1.hours))) + assertEquals(3, calls) + } finally { + second.close() + } + } + + @Test + fun invalidateAll_globalWatermarkIsObservedAcrossNamespacesByFreshStore() = runTest { + var calls = 0 + val sharedBookkeeper = InMemoryBookkeeper() + val sharedSot = createSourceOfTruth() + val clock = FakeWallClock(now = 100L) + fun buildStore(): Store = + restartedStore(sharedBookkeeper, sharedSot, clock) { "v${++calls}" } + val keyA = NamespacedTestKey("a", "1") + val keyB = NamespacedTestKey("b", "1") + + val first = buildStore() + try { + assertEquals("v1", first.get(keyA)) + assertEquals("v2", first.get(keyB)) + first.invalidateAll() + } finally { + first.close() + } + + val second = buildStore() + try { + assertEquals("v3", second.get(keyA, Freshness.MaxAge(1.hours))) + assertEquals("v4", second.get(keyB, Freshness.MaxAge(1.hours))) + assertEquals(4, calls) + } finally { + second.close() + } + } + + @Test + fun freshSidecarWithoutInvalidation_servesHydratedValueAfterFreshStoreStarts() = runTest { + var calls = 0 + val sharedBookkeeper = InMemoryBookkeeper() + val sharedSot = createSourceOfTruth() + val clock = FakeWallClock(now = 100L) + fun buildStore(): Store = + restartedStore(sharedBookkeeper, sharedSot, clock) { "v${++calls}" } + + val first = buildStore() + try { + assertEquals("v1", first.get(TestKey("1"))) + } finally { + first.close() + } + + val second = buildStore() + try { + assertEquals("v1", second.get(TestKey("1"), Freshness.MaxAge(1.hours))) + assertEquals(1, calls) + } finally { + second.close() + } + } + + private fun restartedStore( + sharedBookkeeper: Bookkeeper, + sharedSot: SourceOfTruth, + clock: FakeWallClock, + fetch: suspend (K) -> V, + ): Store = + storeWith(clock = clock, bookkeeper = sharedBookkeeper) { + fetcher(fetch) + persistence(sharedSot) + } +} + +@OptIn(ExperimentalStoreApi::class) +class StoreDurableMaintenanceConformanceTest : StoreDurableMaintenanceConformance() { + override fun createSourceOfTruth(): SourceOfTruth = + InMemorySourceOfTruth() +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceFailureTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceFailureTest.kt new file mode 100644 index 000000000..1ce0be684 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreDurableMaintenanceFailureTest.kt @@ -0,0 +1,263 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@OptIn(ExperimentalStoreApi::class) +class StoreDurableMaintenanceFailureTest { + @Test + fun invalidate_whenMarkPaused_doesNotSignalActiveStream_thenFailureIsTyped() = runTest { + val entered = kotlinx.coroutines.CompletableDeferred() + val release = kotlinx.coroutines.CompletableDeferred() + val expectedCause = IllegalStateException("mark unavailable") + val durableBookkeeper = + RecordingBookkeeper(markStaleFailure = expectedCause).also { + it.markEntered = entered + it.releaseMark = release + } + var calls = 0 + val store = store { + fetcher { "v${++calls}" } + bookkeeper(durableBookkeeper) + } + + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + val invalidation = backgroundScope.async { runCatching { store.invalidate(TestKey("1")) } } + entered.await() + try { + assertTrue(entered.isCompleted, "invalidate must enter durable mark before signaling") + expectNoEvents() + assertEquals(1, calls) + release.complete(Unit) + val failure = assertIs(invalidation.await().exceptionOrNull()) + assertPersistenceCause(expectedCause, failure) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } finally { + release.complete(Unit) + } + } + store.close() + } + + @Test + fun invalidateAll_whenGlobalWatermarkFails_isTypedAndDoesNotSignalActiveStream() = runTest { + val expectedCause = IllegalStateException("global watermark unavailable") + val durableBookkeeper = RecordingBookkeeper(advanceWatermarkFailure = expectedCause) + var calls = 0 + val store = store { + fetcher { "v${++calls}" } + bookkeeper(durableBookkeeper) + } + + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + val failure = assertFailsWith { store.invalidateAll() } + assertPersistenceCause(expectedCause, failure) + expectNoEvents() + assertEquals(1, calls) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun invalidateNamespace_whenWatermarkAdvanceFails_isTypedAndDoesNotSignalActiveStream() = + runTest { + val expectedCause = IllegalStateException("namespace watermark unavailable") + val durableBookkeeper = RecordingBookkeeper(advanceWatermarkFailure = expectedCause) + var calls = 0 + val store = store { + fetcher { "v${++calls}" } + bookkeeper(durableBookkeeper) + } + + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + val failure = assertFailsWith { + store.invalidateNamespace(StoreNamespace("test")) + } + assertPersistenceCause(expectedCause, failure) + expectNoEvents() + assertEquals(1, calls) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun clear_whenSotDeleteFails_isTypedLeavesRowAndResidenceAndDoesNotForget() = runTest { + val events = mutableListOf() + val expectedCause = IllegalStateException("key delete unavailable") + val durableBookkeeper = RecordingBookkeeper(events = events) + val backing = InMemorySourceOfTruth() + val durableSot = RecordingSourceOfTruth(backing, events) + val key = TestKey("1") + val store = store { + fetcher { "v1" } + persistence(durableSot) + bookkeeper(durableBookkeeper) + } + assertEquals("v1", store.get(key)) + durableSot.deleteFailure = expectedCause + + val failure = assertFailsWith { store.clear(key) } + assertPersistenceCause(expectedCause, failure) + assertEquals("v1", backing.reader(key).first()) + assertEquals("v1", store.get(key, Freshness.LocalOnly)) + assertTrue(events.none { it == "forget:test/1" }) + store.close() + } + + @Test + // Per-key forget remains operationally infallible; this fake is defensive boundary hardening. + fun clear_whenForgetViolatesContract_isTypedAfterDelete() = runTest { + val expectedCause = IllegalStateException("forget contract violation") + val durableBookkeeper = RecordingBookkeeper(forgetFailure = expectedCause) + val durableSot = InMemorySourceOfTruth() + val store = store { + fetcher { "v1" } + persistence(durableSot) + bookkeeper(durableBookkeeper) + } + val key = TestKey("1") + assertEquals("v1", store.get(key)) + + val failure = assertFailsWith { store.clear(key) } + assertPersistenceCause(expectedCause, failure) + assertNull(durableSot.reader(key).first(), "delete must remain applied before forget fails") + store.close() + } + + @Test + fun clearNamespace_whenForgetFails_isTypedAfterDeleteAndKeepsOtherNamespace() = runTest { + val events = mutableListOf() + val expectedCause = IllegalStateException("namespace forget unavailable") + val durableBookkeeper = + RecordingBookkeeper(events = events, forgetNamespaceFailure = expectedCause) + val backing = InMemorySourceOfTruth() + val durableSot = RecordingSourceOfTruth(backing, events) + val a = NamespacedTestKey("a", "1") + val b = NamespacedTestKey("b", "1") + val store = store { + fetcher { if (it.namespace.value == "a") "va" else "vb" } + persistence(durableSot) + bookkeeper(durableBookkeeper) + } + store.get(a) + store.get(b) + + val failure = assertFailsWith { + store.clearNamespace(StoreNamespace("a")) + } + assertPersistenceCause(expectedCause, failure) + assertNull(backing.reader(a).first()) + assertEquals("vb", backing.reader(b).first()) + assertTrue( + events.indexOf("deleteNamespace:a") in 0 until events.indexOf("forgetNamespace:a"), + ) + store.close() + } + + @Test + fun clearAll_whenForgetFails_isTypedAfterDeleteAndOrdersSteps() = runTest { + val events = mutableListOf() + val expectedCause = IllegalStateException("global forget unavailable") + val durableBookkeeper = RecordingBookkeeper(events = events, forgetAllFailure = expectedCause) + val backing = InMemorySourceOfTruth() + val durableSot = RecordingSourceOfTruth(backing, events) + val a = NamespacedTestKey("a", "1") + val b = NamespacedTestKey("b", "1") + val store = store { + fetcher { "v" } + persistence(durableSot) + bookkeeper(durableBookkeeper) + } + store.get(a) + store.get(b) + + val failure = assertFailsWith { store.clearAll() } + assertPersistenceCause(expectedCause, failure) + assertNull(backing.reader(a).first()) + assertNull(backing.reader(b).first()) + assertTrue(events.indexOf("deleteAll") in 0 until events.indexOf("forgetAll")) + store.close() + } + + @Test + fun clearNamespace_whenBulkDeleteFails_isTypedDoesNotForgetAndRowsRemain() = runTest { + val events = mutableListOf() + val expectedCause = IllegalStateException("namespace delete unavailable") + val durableBookkeeper = RecordingBookkeeper(events = events) + val backing = InMemorySourceOfTruth() + val durableSot = RecordingSourceOfTruth(backing, events) + val a = NamespacedTestKey("a", "1") + val b = NamespacedTestKey("b", "1") + val store = store { + fetcher { if (it.namespace.value == "a") "va" else "vb" } + persistence(durableSot) + bookkeeper(durableBookkeeper) + } + store.get(a) + store.get(b) + durableSot.deleteNamespaceFailure = expectedCause + + val failure = assertFailsWith { + store.clearNamespace(StoreNamespace("a")) + } + assertPersistenceCause(expectedCause, failure) + assertTrue(events.none { it == "forgetNamespace:a" }) + assertEquals("va", backing.reader(a).first()) + assertEquals("vb", backing.reader(b).first()) + store.close() + } + + @Test + fun clearAll_whenBulkDeleteFails_isTypedDoesNotForgetAndRowsRemain() = runTest { + val events = mutableListOf() + val expectedCause = IllegalStateException("global delete unavailable") + val durableBookkeeper = RecordingBookkeeper(events = events) + val backing = InMemorySourceOfTruth() + val durableSot = RecordingSourceOfTruth(backing, events) + val a = NamespacedTestKey("a", "1") + val b = NamespacedTestKey("b", "1") + val store = store { + fetcher { "v-${it.namespace.value}" } + persistence(durableSot) + bookkeeper(durableBookkeeper) + } + store.get(a) + store.get(b) + durableSot.deleteAllFailure = expectedCause + + val failure = assertFailsWith { store.clearAll() } + assertPersistenceCause(expectedCause, failure) + assertTrue(events.none { it == "forgetAll" }) + assertEquals("v-a", backing.reader(a).first()) + assertEquals("v-b", backing.reader(b).first()) + store.close() + } + + private fun assertPersistenceCause( + expectedCause: Throwable, + failure: StoreException, + ) { + val persistence = assertIs(failure.error) + assertSame(expectedCause, persistence.cause) + assertSame(expectedCause, failure.cause) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionConformanceTest.kt new file mode 100644 index 000000000..df68a35e2 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionConformanceTest.kt @@ -0,0 +1,387 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.internal.RealStore +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class) +class StoreEvictionConformanceTest { + @Test + fun evictedEngine_recreation_semanticallyInvisible() = runTest(timeout = 60.seconds) { + val key = TestKey("A") + val clock = FakeWallClock(now = 1_000L) + val secondFetchGate = CompletableDeferred() + val thirdFetchGate = CompletableDeferred() + var aFetches = 0 + val store = + storeWith(clock = clock) { + maxIdleKeys(4) + fetcher { requested -> + if (requested.canonicalId() != "A") { + "${requested.canonicalId()}-value" + } else { + when (++aFetches) { + 2 -> secondFetchGate.await() + 3 -> thirdFetchGate.await() + } + "A-v$aFetches" + } + } + } as RealStore + + try { + assertEquals("A-v1", store.get(key)) + clock.now += 1_000L + store.invalidate(key) + + val destroyedBeforeFirstChurn = store.destroyedEngineCountForTest() + repeat(9) { index -> store.get(TestKey("first-churn-$index")) } + awaitUntil { store.destroyedEngineCountForTest() > destroyedBeforeFirstChurn } + val createdBeforeFirstRecreation = store.createdEngineCountForTest() + + store.stream(key).test { + val stale = assertIs>(awaitItem()) + assertEquals("A-v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + assertEquals( + createdBeforeFirstRecreation + 1L, + store.createdEngineCountForTest(), + "A must be recreated after leaving the idle LRU", + ) + + secondFetchGate.complete(Unit) + val fresh = awaitData("A-v2") + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + cancelAndIgnoreRemainingEvents() + } + + clock.now += 1_000L + store.invalidateNamespace(StoreNamespace("test")) + val destroyedBeforeSecondChurn = store.destroyedEngineCountForTest() + repeat(9) { index -> store.get(TestKey("second-churn-$index")) } + awaitUntil { store.destroyedEngineCountForTest() > destroyedBeforeSecondChurn } + val createdBeforeSecondRecreation = store.createdEngineCountForTest() + + store.stream(key).test { + val stale = assertIs>(awaitItem()) + assertEquals("A-v2", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + assertEquals( + createdBeforeSecondRecreation + 1L, + store.createdEngineCountForTest(), + "the namespace-invalidated A engine must be recreated", + ) + thirdFetchGate.complete(Unit) + awaitData("A-v3") + cancelAndIgnoreRemainingEvents() + } + } finally { + secondFetchGate.complete(Unit) + thirdFetchGate.complete(Unit) + store.close() + } + } + + @Test + fun memoryCache_neverDivergesFromDurableTruth() = runTest(timeout = 60.seconds) { + val key = TestKey("truth") + val sourceOfTruth = + CountingSourceOfTruth(InMemorySourceOfTruth()) + var fetches = 0 + val store = + store { + persistence(sourceOfTruth) + fetcher { "fetched-${++fetches}" } + } + + try { + assertEquals("fetched-1", store.get(key)) + assertDurableRowMatchesResidence(sourceOfTruth, store, key) + + store.invalidate(key) + assertDurableRowMatchesResidence(sourceOfTruth, store, key) + + assertEquals("fetched-2", store.get(key, Freshness.MustBeFresh)) + assertDurableRowMatchesResidence(sourceOfTruth, store, key) + + store.clear(key) + assertDurableRowMatchesResidence(sourceOfTruth, store, key) + + store.stream(key, Freshness.LocalOnly).test { + val missing = assertIs(awaitItem()) + assertIs(missing.error) + sourceOfTruth.write(key, "external") + assertEquals("external", awaitData("external").value) + assertDurableRowMatchesResidence(sourceOfTruth, store, key) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun quiescentKeys_parkInIdle_boundedByMaxIdleKeys() = runTest(timeout = 60.seconds) { + val store = + store { + maxIdleKeys(8) + fetcher { "value-${it.canonicalId()}" } + } as RealStore + + try { + repeat(64) { index -> store.get(TestKey("idle-$index")) } + awaitUntil { + store.residentEngineCountForTest() == 8 && + store.idleEngineCountForTest() == 8 + } + + assertEquals(8, store.residentEngineCountForTest()) + assertEquals(8, store.idleEngineCountForTest()) + assertEquals(64L, store.createdEngineCountForTest()) + assertEquals(56L, store.destroyedEngineCountForTest()) + } finally { + store.close() + } + } + + @Test + fun activeCollector_pinsEngine_acrossChurn() = runTest(timeout = 60.seconds) { + val key = TestKey("A") + val firstData = CompletableDeferred() + val refreshedData = CompletableDeferred() + val collectorOutcome = CompletableDeferred() + var aFetches = 0 + val store = + store { + maxIdleKeys(2) + fetcher { requested -> + if (requested.canonicalId() == "A") { + "A-v${++aFetches}" + } else { + "${requested.canonicalId()}-value" + } + } + } as RealStore + val collector = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + val outcome = + runCatching { + store.stream(key).collect { result -> + when (result) { + is StoreResult.Data -> { + if (result.value == "A-v1") firstData.complete(Unit) + if (result.value == "A-v2" && !result.isStale) { + refreshedData.complete(Unit) + } + } + is StoreResult.Error -> + throw AssertionError("unexpected Store error: ${result.error}") + is StoreResult.Loading, + is StoreResult.Revalidated, + -> Unit + } + } + } + collectorOutcome.complete(outcome.exceptionOrNull()) + } + + try { + firstData.await() + val destroyedBeforeChurn = store.destroyedEngineCountForTest() + repeat(100) { index -> store.get(TestKey("active-churn-$index")) } + awaitUntil { + val resident = store.residentEngineCountForTest() + store.destroyedEngineCountForTest() > destroyedBeforeChurn && + resident == 3 && + store.idleEngineCountForTest() == 2 && + store.createdEngineCountForTest() - store.destroyedEngineCountForTest() == + 3L + } + + assertTrue(collector.isActive) + assertFalse(collectorOutcome.isCompleted) + assertEquals(3, store.residentEngineCountForTest()) + assertEquals(2, store.idleEngineCountForTest()) + assertEquals( + 3L, + store.createdEngineCountForTest() - store.destroyedEngineCountForTest(), + ) + + store.invalidate(key) + refreshedData.await() + assertEquals(2, aFetches) + assertTrue(collector.isActive, "churn must not close-cancel a held engine") + assertFalse(collectorOutcome.isCompleted) + } finally { + collector.cancelAndJoin() + store.close() + } + } + + @Test + fun inFlightFetch_pinsEngine_acrossWaiterCancellation_andCommits() = + runTest(timeout = 60.seconds) { + val key = TestKey("A") + val fetchStarted = CompletableDeferred() + val fetchGate = CompletableDeferred() + val sourceOfTruth = InMemorySourceOfTruth() + var aFetches = 0 + val store = + store { + maxIdleKeys(2) + persistence(sourceOfTruth) + fetcher { requested -> + if (requested.canonicalId() == "A") { + aFetches += 1 + fetchStarted.complete(Unit) + fetchGate.await() + "A-v$aFetches" + } else { + "${requested.canonicalId()}-value" + } + } + } as RealStore + val waiter = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + store.get(key) + } + + try { + fetchStarted.await() + waiter.cancelAndJoin() + repeat(40) { index -> store.get(TestKey("fetch-churn-$index")) } + awaitUntil { + val resident = store.residentEngineCountForTest() + resident == 3 && + store.idleEngineCountForTest() == 2 && + store.createdEngineCountForTest() - store.destroyedEngineCountForTest() == + 3L + } + assertEquals(3, store.residentEngineCountForTest()) + assertEquals(2, store.idleEngineCountForTest()) + assertEquals( + 3L, + store.createdEngineCountForTest() - store.destroyedEngineCountForTest(), + ) + + fetchGate.complete(Unit) + awaitUntil { + sourceOfTruth.reader(key).first() == "A-v1" && + store.createdEngineCountForTest() - store.destroyedEngineCountForTest() == + store.residentEngineCountForTest().toLong() && + store.idleEngineCountForTest() == store.residentEngineCountForTest() + } + + assertEquals(1, aFetches) + assertEquals("A-v1", store.get(key)) + assertEquals(1, aFetches, "the committed value must be reused without refetch") + } finally { + fetchGate.complete(Unit) + waiter.cancelAndJoin() + store.close() + } + } + + private suspend fun ReceiveTurbine>.awaitData( + expected: String, + ): StoreResult.Data { + while (true) { + when (val result = awaitItem()) { + is StoreResult.Data -> if (result.value == expected) return result + is StoreResult.Error -> throw AssertionError("unexpected Store error: ${result.error}") + is StoreResult.Loading, + is StoreResult.Revalidated, + -> Unit + } + } + } + + private suspend fun assertDurableRowMatchesResidence( + sourceOfTruth: CountingSourceOfTruth, + store: Store, + key: TestKey, + ) { + val durableRow = sourceOfTruth.peek(key) + val readerCallsBeforeObservation = sourceOfTruth.readerCalls + if (durableRow == null) { + val failure = + assertFailsWith { + store.get(key, Freshness.LocalOnly) + } + assertIs(failure.error) + // With no resident envelope, LocalOnly must consult SoT to establish typed Missing. + // Reading absence cannot repair a divergence because there is no durable row to copy. + assertEquals( + readerCallsBeforeObservation + 1, + sourceOfTruth.readerCalls, + "absent residence must be confirmed by exactly one SourceOfTruth reader", + ) + } else { + assertEquals(durableRow, store.get(key, Freshness.LocalOnly)) + assertEquals( + readerCallsBeforeObservation, + sourceOfTruth.readerCalls, + "LocalOnly must not hydrate and mask a lost non-null residence", + ) + } + } + + @OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) + private class CountingSourceOfTruth( + private val delegate: SourceOfTruth, + ) : SourceOfTruth { + private val readerCallState = MutableStateFlow(0) + + val readerCalls: Int + get() = readerCallState.value + + override fun reader(key: K): Flow { + readerCallState.update { count -> count + 1 } + return delegate.reader(key) + } + + override suspend fun write( + key: K, + value: V, + ) { + delegate.write(key, value) + } + + override suspend fun delete(key: K) { + delegate.delete(key) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + delegate.deleteNamespace(namespace) + } + + override suspend fun deleteAll() { + delegate.deleteAll() + } + + suspend fun peek(key: K): V? = delegate.reader(key).first() + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionStressTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionStressTest.kt new file mode 100644 index 000000000..f7940976b --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreEvictionStressTest.kt @@ -0,0 +1,114 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.internal.RealStore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class) +class StoreEvictionStressTest { + @Test + fun churn10kKeyCycles_neverEvictsHeldEngines_andResidencyStaysBounded() = + runTest(timeout = 120.seconds) { + val allWorkersHolding = CompletableDeferred() + val holdingWorkersLock = Mutex() + var holdingWorkers = 0 + val keys = List(KEY_SPACE) { index -> TestKey("stress-$index") } + val store = + store { + maxIdleKeys(16) + fetcher { "value" } + } as RealStore + + try { + withContext(Dispatchers.Default) { + coroutineScope { + repeat(WORKERS) { worker -> + launch { + val key = keys[worker] + store.withEngine(key) { + holdingWorkersLock.withLock { + holdingWorkers += 1 + if (holdingWorkers == WORKERS) { + allWorkersHolding.complete(Unit) + } + } + allWorkersHolding.await() + repeat(STEPS_PER_WORKER) { operation -> + when (operation % 4) { + 0 -> store.get(key) + 1 -> store.stream(key).awaitFirstDataOrThrow() + 2 -> { + store.invalidate(key) + // Settle refresh before clear; CachedOrFetch would + // deliberately race its background refresh. + store.get(key, Freshness.MustBeFresh) + } + 3 -> store.clear(key) + else -> error("unreachable operation") + } + } + } + } + } + } + } + + assertTrue(WORKERS * STEPS_PER_WORKER >= 10_000) + awaitUntil { + val resident = store.residentEngineCountForTest() + store.createdEngineCountForTest() == WORKERS.toLong() && + store.destroyedEngineCountForTest() == 16L && + resident == 16 && + store.idleEngineCountForTest() == 16 && + store.createdEngineCountForTest() - store.destroyedEngineCountForTest() == + 16L + } + assertEquals(WORKERS.toLong(), store.createdEngineCountForTest()) + assertEquals(16L, store.destroyedEngineCountForTest()) + assertEquals(16, store.residentEngineCountForTest()) + assertEquals(16, store.idleEngineCountForTest()) + assertEquals( + 16L, + store.createdEngineCountForTest() - store.destroyedEngineCountForTest(), + ) + + store.close() + store.awaitTerminationForTest() + assertEquals(0, store.residentEngineCountForTest()) + } finally { + store.close() + } + } + + private suspend fun Flow>.awaitFirstDataOrThrow() { + first { result -> + when (result) { + is StoreResult.Data -> true + is StoreResult.Error -> + throw AssertionError("unexpected Store error: ${result.error}") + is StoreResult.Loading, + is StoreResult.Revalidated, + -> false + } + } + } + + private companion object { + const val WORKERS = 32 + const val STEPS_PER_WORKER = 314 + // Twice the idle cap keeps every worker pinned above the eviction threshold. + const val KEY_SPACE = WORKERS + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt new file mode 100644 index 000000000..b949721b6 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationConformanceTest.kt @@ -0,0 +1,851 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.turbineScope +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.flow.produceIn +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +open class StoreInvalidationConformanceTest : SourceOfTruthSubstitutionTest() { + // An active stream signaled by invalidate observes refetched data. + @Test + fun invalidate_activeStream_observesRefetchedData() = runTest { + var calls = 0 + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + val key = TestKey("1") + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + val seedReader = + store.awaitLocalOnlyMissingReaderBarrier(key, backgroundScope) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + store.stream(key).test { + assertIs(awaitItem()) + val initial = assertIs>(awaitItem()) + assertEquals("v1", initial.value) + assertFalse(initial.isStale) + assertFalse(initial.refreshing) + seedReader.cancel() + + store.invalidate(key) + secondFetchStarted.awaitFromDefault() + releaseSecondFetch.complete(Unit) + + var fresh = assertIs>(awaitItem()) + var queuedStaleReplays = 0 + while (fresh.value == "v1") { + queuedStaleReplays += 1 + assertEquals(1, queuedStaleReplays, "more than one queued stale replay") + assertTrue(fresh.isStale) + assertTrue(fresh.refreshing) + fresh = assertIs>(awaitItem()) + } + assertEquals("v2", fresh.value) + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + expectNoEvents() + assertEquals(2, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecondFetch.complete(Unit) + store.closeAndSettleForTest() + } + } + + // Pinned SWR posture: get on a stale resident serves stale now and refetches in background. + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun getOnStaleResident_servesStaleThenRefetchesInBackground() = runTest { + var calls = 0 + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + val key = TestKey("1") + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + try { + val seedReader = + store.awaitLocalOnlyMissingReaderBarrier(key, backgroundScope) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + turbineScope { + val initialCollector = store.stream(key).testIn(backgroundScope) + assertIs(initialCollector.awaitItem()) + val initial = assertIs>(initialCollector.awaitItem()) + assertEquals("v1", initial.value) + assertFalse(initial.isStale) + assertFalse(initial.refreshing) + seedReader.cancel() + + store.invalidate(key) + + assertEquals("v1", store.get(key)) // stale served immediately, not blocked + secondFetchStarted.awaitFromDefault() + val collector = store.stream(key).testIn(backgroundScope) + val stale = assertIs>(collector.awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + // The stale frame precedes this collector's ticket-watcher enrollment. Drain its + // continuation while fetch 2 is gated so the collector joins before settlement. + runCurrent() + releaseSecondFetch.complete(Unit) + var fresh = assertIs>(collector.awaitItem()) + var queuedStaleReplays = 0 + while (fresh.value == "v1") { + queuedStaleReplays += 1 + assertEquals(1, queuedStaleReplays, "more than one queued stale replay") + assertTrue(fresh.isStale) + assertTrue(fresh.refreshing) + fresh = assertIs>(collector.awaitItem()) + } + assertEquals("v2", fresh.value) + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + collector.expectNoEvents() + initialCollector.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + assertEquals("v2", store.get(key)) + assertEquals(2, calls) // background refetch and stream fetch single-flighted + } finally { + releaseSecondFetch.complete(Unit) + store.closeAndSettleForTest() + } + } + + // Honesty of age / isStale / refreshing on emissions. + @Test + fun staleResident_newCollector_seesHonestFlagsThenFreshData() = runTest { + var calls = 0 + val secondStarted = CompletableDeferred() + val secondGate = CompletableDeferred() + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondStarted.complete(Unit) + secondGate.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + val key = TestKey("1") + val seedReader = + store.awaitLocalOnlyMissingReaderBarrier(key, backgroundScope) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + turbineScope { + val initialCollector = store.stream(key).testIn(backgroundScope) + assertIs(initialCollector.awaitItem()) + val initial = assertIs>(initialCollector.awaitItem()) + assertEquals("v1", initial.value) + assertFalse(initial.isStale) + assertFalse(initial.refreshing) + seedReader.cancel() + + store.invalidate(key) + secondStarted.awaitFromDefault() + + val collector = store.stream(key).testIn(backgroundScope) + val stale = assertIs>(collector.awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + assertTrue(stale.age >= Duration.ZERO) + + secondGate.complete(Unit) + + var fresh = assertIs>(collector.awaitItem()) + var queuedStaleReplays = 0 + while (fresh.value == "v1") { + queuedStaleReplays += 1 + assertEquals(1, queuedStaleReplays, "more than one queued stale replay") + assertTrue(fresh.isStale) + assertTrue(fresh.refreshing) + fresh = assertIs>(collector.awaitItem()) + } + assertEquals("v2", fresh.value) + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + collector.expectNoEvents() + assertEquals(2, calls) + initialCollector.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + secondGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + // Clear on an active stream: absent transition (Loading), then refetched data, never stale replay. + @Test + fun clear_activeStream_emitsLoadingThenRefetchedData() = runTest { + var calls = 0 + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + val key = TestKey("1") + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() // hold the refetch so Loading is observable + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + val seedReader = + store.awaitLocalOnlyMissingReaderBarrier(key, backgroundScope) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + store.stream(key).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + seedReader.cancel() + + prepareNextReaderDelivery(key) + store.clear(key) + + assertIs(awaitItem()) // honest absent transition + secondFetchStarted.awaitFromDefault() + awaitCurrentReaderFirstDelivery(key) + assertEquals(2, calls) + releaseSecondFetch.complete(Unit) + assertEquals("v2", assertIs>(awaitItem()).value) + expectNoEvents() + assertEquals(2, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecondFetch.complete(Unit) + store.closeAndSettleForTest() + } + } + + // Clear during an in-flight fetch discards the commit; no resurrection. + @Test + fun clearDuringInFlightFetch_commitDiscarded_noResurrection() = runTest { + var calls = 0 + val firstStarted = CompletableDeferred() + val firstGate = CompletableDeferred() + val store = testStore { + fetcher { + calls++ + if (calls == 1) { + firstStarted.complete(Unit) + firstGate.await() + "doomed-v1" + } else { + "v$calls" + } + } + } + + try { + val waiter = backgroundScope.async { runCatching { store.get(TestKey("1")) } } + firstStarted.awaitFromDefault() + + store.clear(TestKey("1")) + firstGate.complete(Unit) + + val failure = + withContext(Dispatchers.Default) { + waiter.await() + }.exceptionOrNull() + val exception = assertIs(failure) + val missing = assertIs(exception.error) + assertEquals("1", missing.key.canonicalId()) + assertTrue(exception.message!!.contains("test/1")) // which key + assertTrue(exception.message!!.contains("clear")) // what happened + + assertEquals("v2", store.get(TestKey("1"))) // fresh fetch, never "doomed-v1" + } finally { + firstGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun clearDuringInFlightFetch_thatFails_waiterObservesMissing() = runTest { + val fetchStarted = CompletableDeferred() + val fetchGate = CompletableDeferred() + val store = testStore { + fetcher { + fetchStarted.complete(Unit) + fetchGate.await() + error("fetch failed after clear") + } + } + + try { + val waiter = backgroundScope.async { runCatching { store.get(TestKey("1")) } } + fetchStarted.awaitFromDefault() + + store.clear(TestKey("1")) + fetchGate.complete(Unit) + + val failure = + withContext(Dispatchers.Default) { + waiter.await() + }.exceptionOrNull() + val exception = assertIs(failure) + assertIs(exception.error) + assertTrue(exception.message!!.contains("test/1")) + assertTrue(exception.message!!.contains("clear")) + } finally { + fetchGate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun invalidateNamespace_touchesOnlyMatchingNamespace() = runTest { + var aCalls = 0 + var bCalls = 0 + val a2Started = CompletableDeferred() + val a2Gate = CompletableDeferred() + val keyA = NamespacedTestKey("a", "1") + val keyB = NamespacedTestKey("b", "1") + val store = testStore { + fetcher { key -> + if (key.namespace.value == "a") { + when (++aCalls) { + 1 -> "a1" + 2 -> { + a2Started.complete(Unit) + a2Gate.await() + "a2" + } + else -> error("unexpected namespace-a fetch call $aCalls") + } + } else { + when (++bCalls) { + 1 -> "b1" + else -> error("unexpected namespace-b fetch call $bCalls") + } + } + } + } + + try { + val seedReaderA = + store.awaitLocalOnlyMissingReaderBarrier(keyA, backgroundScope) + val seedReaderB = + store.awaitLocalOnlyMissingReaderBarrier(keyB, backgroundScope) + assertEquals(0, aCalls) + assertEquals(0, bCalls) + awaitCurrentReaderFirstDelivery(keyA) + awaitCurrentReaderFirstDelivery(keyB) + + turbineScope { + val initialCollector = store.stream(keyA).testIn(backgroundScope) + assertIs(initialCollector.awaitItem()) + val initial = assertIs>(initialCollector.awaitItem()) + assertEquals("a1", initial.value) + assertFalse(initial.isStale) + assertFalse(initial.refreshing) + assertEquals("b1", store.get(keyB)) + seedReaderA.cancel() + seedReaderB.cancel() + + store.invalidateNamespace(StoreNamespace("a")) + a2Started.awaitFromDefault() + + assertEquals("b1", store.get(keyB)) // untouched, no refetch + assertEquals(1, bCalls) + assertEquals("a1", store.get(keyA)) // stale served immediately + + val collector = store.stream(keyA).testIn(backgroundScope) + val stale = assertIs>(collector.awaitItem()) + assertEquals("a1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + a2Gate.complete(Unit) + var fresh = assertIs>(collector.awaitItem()) + var queuedStaleReplays = 0 + while (fresh.value == "a1") { + queuedStaleReplays += 1 + assertEquals(1, queuedStaleReplays, "more than one queued stale replay") + assertTrue(fresh.isStale) + assertTrue(fresh.refreshing) + fresh = assertIs>(collector.awaitItem()) + } + assertEquals("a2", fresh.value) + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + collector.expectNoEvents() + initialCollector.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + assertEquals(2, aCalls) + } finally { + a2Gate.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun invalidateNamespace_wakesOnlyMatchingResidentCollector() = runTest { + var aCalls = 0 + var bCalls = 0 + val aRefreshStarted = CompletableDeferred() + val releaseARefresh = CompletableDeferred() + val keyA = NamespacedTestKey("a", "1") + val keyB = NamespacedTestKey("b", "1") + val store = testStore { + fetcher { key -> + if (key.namespace.value == "a") { + when (++aCalls) { + 1 -> "a1" + 2 -> { + aRefreshStarted.complete(Unit) + releaseARefresh.await() + "a2" + } + else -> error("unexpected namespace-a fetch call $aCalls") + } + } else { + when (++bCalls) { + 1 -> "b1" + else -> error("unexpected namespace-b fetch call $bCalls") + } + } + } + } + + try { + val seedReaderA = + store.awaitLocalOnlyMissingReaderBarrier(keyA, backgroundScope) + val seedReaderB = + store.awaitLocalOnlyMissingReaderBarrier(keyB, backgroundScope) + assertEquals(0, aCalls) + assertEquals(0, bCalls) + awaitCurrentReaderFirstDelivery(keyA) + awaitCurrentReaderFirstDelivery(keyB) + + turbineScope { + val aCollector = store.stream(keyA).testIn(backgroundScope) + val bCollector = store.stream(keyB).testIn(backgroundScope) + assertIs(aCollector.awaitItem()) + assertEquals("a1", assertIs>(aCollector.awaitItem()).value) + assertIs(bCollector.awaitItem()) + assertEquals("b1", assertIs>(bCollector.awaitItem()).value) + seedReaderA.cancel() + seedReaderB.cancel() + + store.invalidateNamespace(StoreNamespace("a")) + aRefreshStarted.awaitFromDefault() + bCollector.expectNoEvents() + assertEquals(1, bCalls) + + releaseARefresh.complete(Unit) + while (true) { + val item = aCollector.awaitItem() + if (item is StoreResult.Data && item.value == "a2") break + } + bCollector.expectNoEvents() + assertEquals(2, aCalls) + assertEquals(1, bCalls) + aCollector.cancelAndIgnoreRemainingEvents() + bCollector.cancelAndIgnoreRemainingEvents() + } + } finally { + releaseARefresh.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun invalidateAll_wakesResidentCollector() = runTest { + var calls = 0 + val refreshStarted = CompletableDeferred() + val releaseRefresh = CompletableDeferred() + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + refreshStarted.complete(Unit) + releaseRefresh.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + val key = TestKey("1") + val seedReader = + store.awaitLocalOnlyMissingReaderBarrier(key, backgroundScope) + assertEquals(0, calls) + awaitCurrentReaderFirstDelivery(key) + + store.stream(key).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + seedReader.cancel() + + store.invalidateAll() + refreshStarted.awaitFromDefault() + releaseRefresh.complete(Unit) + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == "v2") break + } + assertEquals(2, calls) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseRefresh.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun clearNamespace_deletesAffectedRowsAndKeepsOtherNamespace() = runTest { + var calls = 0 + val keyA = NamespacedTestKey("a", "1") + val keyB = NamespacedTestKey("b", "1") + val store = testStore { fetcher { "v${++calls}" } } + + try { + assertEquals("v1", store.get(keyA)) + assertEquals("v2", store.get(keyB)) + store.clearNamespace(StoreNamespace("a")) + + assertEquals("v2", store.get(keyB, Freshness.LocalOnly)) + val missing = assertFailsWith { + store.get(keyA, Freshness.LocalOnly) + } + assertIs(missing.error) + assertEquals("v3", store.get(keyA)) + assertEquals(3, calls) + } finally { + store.closeAndSettleForTest() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun clear_thenNewStreamEmitsLoadingNeverStaleReplay() = runTest { + var calls = 0 + val refetchStarted = CompletableDeferred() + val releaseRefetch = CompletableDeferred() + val key = TestKey("1") + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + refetchStarted.complete(Unit) + releaseRefetch.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + prepareNextReaderDelivery(key) + store.clear(key) + store.stream(key).test { + assertIs(awaitItem()) + refetchStarted.awaitFromDefault() + // The initial Loading precedes ticket-watcher enrollment. Drain that continuation + // while fetch 2 remains gated so post-clear demand joins before the outcome settles. + runCurrent() + awaitCurrentReaderFirstDelivery(key) + assertEquals(2, calls) + releaseRefetch.complete(Unit) + awaitDataValue(expected = "v2") + cancelAndIgnoreRemainingEvents() + } + assertEquals(2, calls) + } finally { + releaseRefetch.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun clearNamespace_activeLocalOnlyStreamObservesMissingWithoutRefetch() = runTest { + var calls = 0 + val key = NamespacedTestKey("a", "1") + val store = + testStore { + fetcher { "v${++calls}" } + } + + try { + assertEquals("v1", store.get(key)) + turbineScope { + val collector = + store.stream(key, Freshness.LocalOnly).testIn(backgroundScope) + val resident = assertIs>(collector.awaitItem()) + assertEquals("v1", resident.value) + assertFalse(resident.isStale) + assertFalse(resident.refreshing) + awaitCurrentReaderFirstDelivery(key) + + store.clearNamespace(StoreNamespace("a")) + + // Fenced clear: an already-active pipeline may queue one duplicate pre-clear + // Data frame. Drain it exactly; Loading then Missing must still follow. + var frame = collector.awaitItem() + var queuedPreClearReplays = 0 + while (frame !is StoreResult.Loading) { + queuedPreClearReplays += 1 + assertEquals(1, queuedPreClearReplays, "more than one queued pre-clear replay") + val replay = assertIs>(frame) + assertEquals("v1", replay.value) + assertFalse(replay.isStale) + assertFalse(replay.refreshing) + frame = collector.awaitItem() + } + val missing = assertIs(collector.awaitItem()) + assertIs(missing.error) + assertFalse(missing.servedStale) + assertEquals(1, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } finally { + store.closeAndSettleForTest() + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun clearNamespace_thenNewStreamEmitsLoadingNeverPreClearData() = runTest { + var calls = 0 + val refetchStarted = CompletableDeferred() + val releaseRefetch = CompletableDeferred() + val key = NamespacedTestKey("a", "1") + val store = testStore { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + refetchStarted.complete(Unit) + releaseRefetch.await() + "v2" + } + else -> error("unexpected fetch call $calls") + } + } + } + + try { + assertEquals("v1", store.get(key)) + turbineScope { + // No shared reader is active during the two bulk-clear sweeps. A retained + // post-clear LocalOnly observer then starts directly from the final generation, + // making its downstream null-delivery acknowledgement unambiguous. + store.clearNamespace(StoreNamespace("a")) + assertEquals(1, calls) + val postClearReader = + store.awaitLocalOnlyMissingReaderBarrier(key, backgroundScope) + assertEquals(1, calls) + awaitCurrentReaderFirstDelivery(key) + + val collector = store.stream(key).testIn(backgroundScope) + assertIs(collector.awaitItem()) + refetchStarted.awaitFromDefault() + // The new collector's initial Loading is sent before StreamDelivery.start installs + // its ticket watcher. Drain that continuation while fetch 2 remains gated so its + // post-clear demand is enrolled before the shared outcome can settle. + runCurrent() + assertEquals(2, calls) + releaseRefetch.complete(Unit) + val fresh = collector.awaitFreshDataAfterClear(forbidden = "v1") + assertEquals("v2", fresh.value) + assertFalse(fresh.isStale) + assertFalse(fresh.refreshing) + collector.expectNoEvents() + val localFresh = assertIs>(postClearReader.receive()) + assertEquals("v2", localFresh.value) + assertFalse(localFresh.isStale) + assertFalse(localFresh.refreshing) + postClearReader.cancel() + collector.cancelAndIgnoreRemainingEvents() + } + assertEquals(2, calls) + } finally { + releaseRefetch.complete(Unit) + store.closeAndSettleForTest() + } + } + + @Test + fun clearAll_dropsResidenceForEveryKey() = runTest { + var calls = 0 + val store = testStore { fetcher { "v${++calls}" } } + + try { + assertEquals("v1", store.get(NamespacedTestKey("a", "1"))) + assertEquals("v2", store.get(NamespacedTestKey("b", "2"))) + + store.clearAll() + + // Residence is gone: both keys refetch. + assertEquals("v3", store.get(NamespacedTestKey("a", "1"))) + assertEquals("v4", store.get(NamespacedTestKey("b", "2"))) + } finally { + store.closeAndSettleForTest() + } + } + + @Test + fun maintenanceAfterClose_failsFastWithDeterministicException() = runTest { + val store = testStore { fetcher { "v" } } + try { + store.close() + + assertEquals( + "Store is closed.", + assertFailsWith { store.invalidate(TestKey("1")) }.message, + ) + assertEquals( + "Store is closed.", + assertFailsWith { store.clearAll() }.message, + ) + } finally { + store.closeAndSettleForTest() + } + } + + private suspend fun app.cash.turbine.ReceiveTurbine>.awaitDataValue( + expected: String, + ) { + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> { + assertEquals(expected, item.value, "pre-clear Data must never replay") + return + } + is StoreResult.Loading -> Unit + is StoreResult.Error -> { + val cause = (item.error as? StoreError.Fetch)?.cause + throw AssertionError( + "unexpected clear-cycle error: ${item.error}; cause=${cause?.message}", + cause, + ) + } + is StoreResult.Revalidated -> throw AssertionError("clear must not revalidate") + } + } + } + + private suspend fun app.cash.turbine.ReceiveTurbine>.awaitFreshDataAfterClear( + forbidden: String, + ): StoreResult.Data { + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> { + assertTrue(item.value != forbidden, "pre-clear Data must never replay") + if (!item.isStale && !item.refreshing) return item + } + is StoreResult.Loading -> Unit + is StoreResult.Error -> { + val cause = (item.error as? StoreError.Fetch)?.cause + throw AssertionError( + "unexpected clear-cycle error: ${item.error}; cause=${cause?.message}", + cause, + ) + } + is StoreResult.Revalidated -> throw AssertionError("clear must not revalidate") + } + } + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } + +private suspend fun Store.awaitLocalOnlyMissingReaderBarrier( + key: K, + scope: kotlinx.coroutines.CoroutineScope, +): ReceiveChannel> { + val collector = stream(key, Freshness.LocalOnly).produceIn(scope) + val missing = assertIs(collector.receive()) + assertIs(missing.error) + assertFalse(missing.servedStale) + return collector +} + +// Preserve the cross-scheduler hop; runTest owns the timeout so broad-graph load cannot +// expire a shorter wall-clock deadline before the causal event reaches Dispatchers.Default. +private suspend fun CompletableDeferred.awaitFromDefault(): T = + withContext(Dispatchers.Default) { + await() + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationStressTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationStressTest.kt new file mode 100644 index 000000000..7f09d294e --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreInvalidationStressTest.kt @@ -0,0 +1,107 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration.Companion.seconds + +abstract class StoreInvalidationStressConformance : SourceOfTruthSubstitutionTest() { + @Test + fun invalidate_burstOf10k_convergesWithoutLosingFinalStaleness() = + runTest(timeout = 240.seconds) { + val callsLock = Mutex() + var calls = 0 + var gateNextFetch = false + val finalFetchEntered = CompletableDeferred() + val finalFetchCall = CompletableDeferred() + val releaseFinalFetch = CompletableDeferred() + val store = testStore { + fetcher { + val (call, shouldGate) = callsLock.withLock { + calls += 1 + val armed = gateNextFetch + gateNextFetch = false + calls to armed + } + if (shouldGate) { + finalFetchCall.complete(call) + finalFetchEntered.complete(Unit) + releaseFinalFetch.await() + } + "v$call" + } + } + val initialDataSeen = CompletableDeferred() + val burstCollector = backgroundScope.launch { + store.stream(TestKey("1")).collect { result -> + if (result is StoreResult.Data) initialDataSeen.complete(Unit) + } + } + + try { + initialDataSeen.awaitFromDefaultContext() + coroutineScope { + repeat(10) { + launch { + repeat(1_000) { + store.invalidate(TestKey("1")) + store.get(TestKey("1")) + } + } + } + } + assertTrue(callsLock.withLock { calls >= 2 }) + + burstCollector.cancelAndJoin() + store.get(TestKey("1"), Freshness.MustBeFresh) + callsLock.withLock { gateNextFetch = true } + store.invalidate(TestKey("1")) + + store.stream(TestKey("1")).test(timeout = 60.seconds) { + val finalStale = assertIs>(awaitItem()) + assertTrue(finalStale.isStale) + assertTrue(finalStale.refreshing) + finalFetchEntered.awaitFromDefaultContext() + val expectedFreshValue = "v${finalFetchCall.awaitFromDefaultContext()}" + releaseFinalFetch.complete(Unit) + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> { + if (!item.isStale && !item.refreshing) { + kotlin.test.assertEquals(expectedFreshValue, item.value) + break + } + } + is StoreResult.Loading -> Unit + is StoreResult.Error -> fail("unexpected stress error: ${item.error}") + is StoreResult.Revalidated -> fail("Success fetches must not revalidate") + } + } + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseFinalFetch.complete(Unit) + burstCollector.cancel() + store.close() + burstCollector.join() + } + } +} + +class StoreInvalidationStressTest : StoreInvalidationStressConformance() + +private suspend fun CompletableDeferred.awaitFromDefaultContext(): T = + withContext(Dispatchers.Default) { + await() + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreResultsTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreResultsTest.kt new file mode 100644 index 000000000..35a248054 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreResultsTest.kt @@ -0,0 +1,115 @@ +package org.mobilenativefoundation.store6.core + +import org.mobilenativefoundation.store6.core.seam.StoreResults +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class) +class StoreResultsTest { + + @Test + fun resultFactories_roundTripEveryPayload() { + assertIs(StoreResults.loading()) + + val data = StoreResults.data( + value = "v", + origin = Origin.MEMORY, + age = 2.seconds, + isStale = false, + refreshing = true, + ) + assertEquals("v", data.value) + assertEquals(Origin.MEMORY, data.origin) + assertEquals(2.seconds, data.age) + assertFalse(data.isStale) + assertTrue(data.refreshing) + + val nullableData: StoreResult.Data = StoreResults.data( + value = null, + origin = Origin.OVERLAY, + age = 3.seconds, + isStale = true, + refreshing = false, + ) + assertNull(nullableData.value) + assertEquals(Origin.OVERLAY, nullableData.origin) + assertEquals(3.seconds, nullableData.age) + assertTrue(nullableData.isStale) + assertFalse(nullableData.refreshing) + + assertEquals(4.seconds, StoreResults.revalidated(age = 4.seconds).age) + + val error: StoreError.Fetch = StoreResults.fetchError(message = "fetch") + val wrapped: StoreResult.Error = StoreResults.error(error = error, servedStale = true) + assertSame(error, wrapped.error) + assertTrue(wrapped.servedStale) + } + + @Test + fun errorFactories_roundTripEveryPayload() { + val fetchCause = IllegalStateException("fetch cause") + val fetch = StoreResults.fetchError(message = "fetch", cause = fetchCause) + assertEquals("fetch", fetch.message) + assertSame(fetchCause, fetch.cause) + assertNull(StoreResults.fetchError(message = "fetch default").cause) + assertNull(StoreResults.fetchError(message = "fetch null", cause = null).cause) + + val persistenceCause = IllegalArgumentException("persistence cause") + val persistence = StoreResults.persistenceError( + message = "persistence", + cause = persistenceCause, + ) + assertEquals("persistence", persistence.message) + assertSame(persistenceCause, persistence.cause) + assertNull(StoreResults.persistenceError(message = "persistence default").cause) + assertNull(StoreResults.persistenceError(message = "persistence null", cause = null).cause) + + val conversionCause = UnsupportedOperationException("conversion cause") + val conversion = StoreResults.conversionError( + message = "conversion", + cause = conversionCause, + ) + assertEquals("conversion", conversion.message) + assertSame(conversionCause, conversion.cause) + assertNull(StoreResults.conversionError(message = "conversion default").cause) + assertNull(StoreResults.conversionError(message = "conversion null", cause = null).cause) + + val freshness = StoreResults.freshnessUnsatisfiable(message = "freshness") + assertEquals("freshness", freshness.message) + + val serverMeta = object : StoreMeta { + override val writtenAtEpochMillis: Long = 42L + override val etag: String = "etag" + } + val conflict = StoreResults.conflict(serverMeta = serverMeta, message = "conflict") + assertSame(serverMeta, conflict.serverMeta) + assertEquals("conflict", conflict.message) + assertNull(StoreResults.conflict(serverMeta = null, message = "no metadata").serverMeta) + + val key = TestKey("missing") + val missing = StoreResults.missing(key = key, message = "missing") + assertSame(key, missing.key) + assertEquals("missing", missing.message) + } + + @Test + fun exceptionFactory_roundTripsErrorCauseAndMessage() { + val error: StoreError.Persistence = StoreResults.persistenceError(message = "persistence") + val cause = IllegalStateException("exception cause") + val exception: StoreException = StoreResults.exception(error = error, cause = cause) + + assertSame(error, exception.error) + assertSame(cause, exception.cause) + assertEquals("persistence", exception.message) + assertNull(StoreResults.exception(error).cause) + + assertEquals("m", StoreResults.exception(StoreResults.fetchError("m")).message) + assertIs(StoreResults.exception(StoreResults.fetchError("m")).error) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRevalidationConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRevalidationConformanceTest.kt new file mode 100644 index 000000000..a36166d6a --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRevalidationConformanceTest.kt @@ -0,0 +1,238 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +abstract class StoreRevalidationConformance : SourceOfTruthSubstitutionTest() { + @Test + fun conditionalRefetch_notModified_emitsOwnerRevalidatedAndClearsStaleness() = runTest { + var calls = 0 + val notModifiedGate = CompletableDeferred() + val store = testStore { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> { + notModifiedGate.await() + FetcherResult.NotModified("e1") + } + // A cold-baseline 304 commits ObsoleteRevalidation and legally self-heals + // with exactly one replanned conditional fetch. + 3 -> FetcherResult.NotModified("e1") + else -> error("unexpected fetch call $calls") + } + } + } + + try { + store.stream(TestKey("1")).test { + assertIs(awaitItem()) + assertEquals("v1", assertIs>(awaitItem()).value) + store.invalidate(TestKey("1")) + notModifiedGate.complete(Unit) + + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> { + if (item.origin == Origin.FETCHER && !item.isStale) { + fail("legacy fresh FETCHER Data must be replaced by Revalidated") + } + } + is StoreResult.Revalidated -> { + assertTrue(item.age >= Duration.ZERO) + break + } + else -> fail("unexpected lifecycle item ${item::class.simpleName}") + } + } + cancelAndIgnoreRemainingEvents() + } + + val callsAfterRevalidated = calls + assertTrue( + callsAfterRevalidated in 2..3, + "the 304 cycle may self-heal one obsolete cold-baseline launch", + ) + assertEquals("v1", store.get(TestKey("1"))) + assertEquals( + callsAfterRevalidated, + calls, + "successful 304 must clear staleness before later planning", + ) + } finally { + notModifiedGate.complete(Unit) + store.close() + } + } + + @Test + fun slowCollector_neverLosesRevalidatedWhileConsecutiveDataConflates() = runTest { + var calls = 0 + val secondFetchEntered = CompletableDeferred() + val releaseNotModified = CompletableDeferred() + val firstDataSeen = CompletableDeferred() + val releaseSlowCollector = CompletableDeferred() + val slowSawRevalidated = CompletableDeferred() + val received = mutableListOf>() + val key = TestKey("1") + val store = testStore { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> { + secondFetchEntered.complete(Unit) + releaseNotModified.await() + FetcherResult.NotModified("e1") + } + // A cold-baseline 304 commits ObsoleteRevalidation and legally self-heals + // with exactly one replanned conditional fetch. + 3 -> FetcherResult.NotModified("e1") + else -> error("unexpected fetch call $calls") + } + } + } + val slowCollector = backgroundScope.launch { + store.stream(key).collect { result -> + received += result + when { + result is StoreResult.Data && !firstDataSeen.isCompleted -> { + firstDataSeen.complete(Unit) + releaseSlowCollector.await() + } + result is StoreResult.Revalidated -> slowSawRevalidated.complete(Unit) + } + } + } + + try { + firstDataSeen.awaitFromDefaultContext() + store.invalidate(key) + secondFetchEntered.awaitFromDefaultContext() + + store.stream(key).test { + val joiningData = assertIs>(awaitItem()) + assertEquals("v1", joiningData.value) + assertTrue(joiningData.isStale) + assertTrue(joiningData.refreshing) + releaseNotModified.complete(Unit) + + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> { + if (item.origin == Origin.FETCHER && !item.isStale) { + fail("legacy fresh FETCHER Data must be replaced by Revalidated") + } + } + is StoreResult.Revalidated -> break + else -> fail("unexpected lifecycle item ${item::class.simpleName}") + } + } + cancelAndIgnoreRemainingEvents() + } + + releaseSlowCollector.complete(Unit) + slowSawRevalidated.awaitFromDefaultContext() + val afterInitialData = received.dropWhile { it !is StoreResult.Data }.drop(1) + val beforeRevalidated = afterInitialData.takeWhile { it !is StoreResult.Revalidated } + assertTrue(afterInitialData.any { it is StoreResult.Revalidated }) + assertTrue( + beforeRevalidated.count { it is StoreResult.Data<*> } <= 1, + "a blocked collector may retain only the latest consecutive Data before Revalidated", + ) + assertTrue(calls in 2..3, "the 304 cycle may self-heal one obsolete cold-baseline launch") + } finally { + releaseNotModified.complete(Unit) + releaseSlowCollector.complete(Unit) + slowCollector.cancel() + store.close() + slowCollector.join() + } + } + + @Test + fun slowCollector_neverLosesPostClearLoadingBeforeRefetchCompletes() = runTest { + var calls = 0 + val firstDataSeen = CompletableDeferred() + val releaseSlowCollector = CompletableDeferred() + val refetchEntered = CompletableDeferred() + val releaseRefetch = CompletableDeferred() + val slowSawLoading = CompletableDeferred() + val slowSawFreshData = CompletableDeferred() + val store = testStore { + fetcher { + val call = ++calls + if (call == 2) { + refetchEntered.complete(Unit) + releaseRefetch.await() + } + "v$call" + } + } + val slowCollector = backgroundScope.launch { + store.stream(TestKey("1")).collect { result -> + when { + result is StoreResult.Data && !firstDataSeen.isCompleted -> { + firstDataSeen.complete(Unit) + releaseSlowCollector.await() + } + result is StoreResult.Loading && firstDataSeen.isCompleted -> + slowSawLoading.complete(Unit) + result is StoreResult.Data && result.value == "v2" -> + slowSawFreshData.complete(Unit) + } + } + } + + try { + firstDataSeen.awaitFromDefaultContext() + store.clear(TestKey("1")) + refetchEntered.awaitFromDefaultContext() + releaseSlowCollector.complete(Unit) + slowSawLoading.awaitFromDefaultContext() + assertTrue(!releaseRefetch.isCompleted, "Loading must survive before refetch is released") + releaseRefetch.complete(Unit) + slowSawFreshData.awaitFromDefaultContext() + assertEquals(2, calls) + } finally { + releaseSlowCollector.complete(Unit) + releaseRefetch.complete(Unit) + slowCollector.cancel() + store.close() + slowCollector.join() + } + } +} + +class StoreRevalidationConformanceTest : StoreRevalidationConformance() + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } + +// Preserve Default-dispatch ordering and let the suite-level runTest bound own cancellation. +private suspend fun CompletableDeferred.awaitFromDefaultContext(): T = + withContext(Dispatchers.Default) { + await() + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRuntimeTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRuntimeTest.kt new file mode 100644 index 000000000..c33d3dabb --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreRuntimeTest.kt @@ -0,0 +1,164 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.seam.KeyEvents +import org.mobilenativefoundation.store6.core.seam.runtime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class StoreRuntimeTest { + private suspend fun app.cash.turbine.ReceiveTurbine>.awaitDataValue( + expected: String, + ): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == expected) return item + } + } + + @Test + fun keyEvents_writtenInvalidatedDeleted_inOrder() = runTest { + val store = store { fetcher { "v" } } + val runtime = assertNotNull(store.runtime()) + runtime.keyEvents.test { + store.get(TestKey("1")) + // Written is tryEmitted BEFORE the ticket completes (placement pin), so it is on the + // bus before this thread resumed from get() -- strictly before the Invalidated below. + val written = assertIs(awaitItem()) + assertEquals("1", written.key.canonicalId()) + assertEquals(Origin.FETCHER, written.origin) + store.invalidate(TestKey("1")) + assertIs(awaitItem()) + store.clear(TestKey("1")) + assertIs(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun writeHandle_apply_emitsSotData_withoutFetch() = runTest { + var fetches = 0 + val store = store { fetcher { fetches++; "fetched" } } + val handle = assertNotNull(store.runtime()).writeHandle + store.stream(TestKey("1")).test { + awaitDataValue("fetched") + handle.apply(TestKey("1"), "applied") + val applied = awaitDataValue("applied") + assertEquals(Origin.SOT, applied.origin) + assertEquals(1, fetches) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun writeHandle_apply_settledEnvelopeKeepsSotOrigin() = runTest { + // Guards the T0-verified recognition amendment: the envelope-freshness recognition sites + // compare envelope.origin == Origin.FETCHER; unless generalized to installed-envelope + // identity, the SoT echo row would not reuse the installed SOT envelope and a fresh + // collection's first Data would expose a FETCHER (or conservative) envelope. + val store = store { fetcher { "fetched" } } + val handle = store.runtime()!!.writeHandle + store.get(TestKey("1")) + handle.apply(TestKey("1"), "applied") + store.stream(TestKey("1")).test { + // Fresh collection after apply and its source-of-truth echo settle. + val first = awaitDataValue("applied") + assertEquals(Origin.SOT, first.origin) + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun writeHandle_apply_echoRowReusesInstalledEnvelope_andExternalWriteStillWins() = runTest { + // The echo is the one matching pre-cutoff row and reuses the installed SOT envelope; a + // later external source-of-truth write is post-cutoff and wins as later authority. + val sot = InMemorySourceOfTruth() + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + var fetches = 0 + val store = store { + fetcher { + when (val call = ++fetches) { + 1 -> "fetched" + 2 -> { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() + "replacement" + } + + else -> error("unexpected fetch call $call") + } + } + persistence(sot) + } + val handle = assertNotNull(store.runtime()).writeHandle + try { + store.stream(TestKey("1")).test { + awaitDataValue("fetched") + handle.apply(TestKey("1"), "applied") + assertEquals(Origin.SOT, awaitDataValue("applied").origin) + sot.write(TestKey("1"), "external") + withContext(Dispatchers.Default) { + secondFetchStarted.await() + } + assertEquals(Origin.SOT, awaitDataValue("external").origin) + releaseSecondFetch.complete(Unit) + cancelAndIgnoreRemainingEvents() + } + } finally { + releaseSecondFetch.complete(Unit) + store.close() + } + } + + @Test + fun writeHandle_markStale_signalsRefetch() = runTest { + var fetches = 0 + val store = store { fetcher { fetches++; "v$fetches" } } + store.stream(TestKey("1")).test { + awaitDataValue("v1") + store.runtime()!!.writeHandle.markStale(TestKey("1")) + awaitDataValue("v2") + cancelAndIgnoreRemainingEvents() + } + store.close() + } + + @Test + fun writeHandle_confirmFresh_clearsStalenessWithoutFetch() = runTest { + var fetches = 0 + val store = store { fetcher { fetches++; "v$fetches" } } + assertEquals("v1", store.get(TestKey("1"))) + store.invalidate(TestKey("1")) + store.runtime()!!.writeHandle.confirmFresh(TestKey("1"), etag = null) + assertEquals("v1", store.get(TestKey("1"))) + assertEquals(1, fetches) + store.close() + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreScopedMaintenanceRaceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreScopedMaintenanceRaceTest.kt new file mode 100644 index 000000000..12e20642e --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreScopedMaintenanceRaceTest.kt @@ -0,0 +1,240 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class) +class StoreScopedMaintenanceRaceTest { + @Test + fun invalidateNamespace_watermarkThenLaterSuccessSuppressesResidentSignal() = runTest { + val successEntered = CompletableDeferred() + val releaseSuccess = CompletableDeferred() + val durableBookkeeper = RecordingBookkeeper() + var calls = 0 + val store = store { + fetcherOfResult { FetcherResult.Success("v${++calls}", etag = "e$calls") } + bookkeeper(durableBookkeeper) + } + val key = NamespacedTestKey("a", "1") + assertEquals("v1", store.get(key)) + durableBookkeeper.successEntered = successEntered + durableBookkeeper.releaseSuccess = releaseSuccess + + val laterSuccess = backgroundScope.async { store.get(key, Freshness.MustBeFresh) } + testScheduler.runCurrent() + try { + awaitFromDefault { successEntered.await() } + val invalidation = backgroundScope.async { + store.invalidateNamespace(StoreNamespace("a")) + } + testScheduler.runCurrent() + assertTrue( + durableBookkeeper.log.contains("advanceStaleWatermark:a"), + "watermark must advance before resident status is rechecked", + ) + releaseSuccess.complete(Unit) + assertEquals("v2", awaitFromDefault { laterSuccess.await() }) + awaitFromDefault { invalidation.await() } + val watermarkIndex = durableBookkeeper.log.indexOf("advanceStaleWatermark:a") + val statusAfterWatermark = + durableBookkeeper.log.indexOfLast { it == "status:a/1" } + assertTrue( + statusAfterWatermark > watermarkIndex, + "resident signaling must reload status after the namespace watermark", + ) + assertEquals("v2", store.get(key)) + assertEquals(2, calls, "later success must satisfy the earlier watermark") + } finally { + releaseSuccess.complete(Unit) + } + store.close() + } + + @Test + fun invalidateAll_globalWatermarkThenLaterSuccessSuppressesResidentSignal() = runTest { + val successEntered = CompletableDeferred() + val releaseSuccess = CompletableDeferred() + val durableBookkeeper = RecordingBookkeeper() + var calls = 0 + val store = store { + fetcherOfResult { FetcherResult.Success("v${++calls}", etag = "e$calls") } + bookkeeper(durableBookkeeper) + } + val key = TestKey("1") + assertEquals("v1", store.get(key)) + durableBookkeeper.successEntered = successEntered + durableBookkeeper.releaseSuccess = releaseSuccess + + val laterSuccess = backgroundScope.async { store.get(key, Freshness.MustBeFresh) } + testScheduler.runCurrent() + try { + awaitFromDefault { successEntered.await() } + val invalidation = backgroundScope.async { store.invalidateAll() } + testScheduler.runCurrent() + assertTrue( + durableBookkeeper.log.contains("advanceGlobalStaleWatermark"), + "global watermark must advance before resident status is rechecked", + ) + releaseSuccess.complete(Unit) + assertEquals("v2", awaitFromDefault { laterSuccess.await() }) + awaitFromDefault { invalidation.await() } + val watermarkIndex = durableBookkeeper.log.indexOf("advanceGlobalStaleWatermark") + val statusAfterWatermark = durableBookkeeper.log.indexOfLast { it == "status:test/1" } + assertTrue( + statusAfterWatermark > watermarkIndex, + "resident signaling must reload status after the global watermark", + ) + assertEquals("v2", store.get(key)) + assertEquals(2, calls) + } finally { + releaseSuccess.complete(Unit) + } + store.close() + } + + @Test + fun clearNamespace_fencesAffectedCommitTailsButAllowsUnrelatedCommit() = runTest { + val backing = InMemorySourceOfTruth() + val gated = PostDeleteGateSourceOfTruth(backing) + val affectedFetchStarted = CompletableDeferred() + val betweenSweepsFetchStarted = CompletableDeferred() + val releaseAffectedFetch = CompletableDeferred() + val counts = mutableMapOf() + val store = store { + fetcher { key -> + val identity = "${key.namespace.value}/${key.canonicalId()}" + val call = counts.getOrElse(identity) { 0 } + 1 + counts[identity] = call + if (identity == "a/1" && call == 2) { + affectedFetchStarted.complete(Unit) + releaseAffectedFetch.await() + } + if (identity == "a/2") betweenSweepsFetchStarted.complete(Unit) + "$identity-v$call" + } + persistence(gated) + } + val affected = NamespacedTestKey("a", "1") + val betweenSweeps = NamespacedTestKey("a", "2") + val unrelated = NamespacedTestKey("b", "1") + store.get(affected) + store.get(unrelated) + val oldTail = backgroundScope.async { runCatching { store.get(affected, Freshness.MustBeFresh) } } + testScheduler.runCurrent() + + try { + awaitFromDefault { affectedFetchStarted.await() } + val clear = backgroundScope.async { store.clearNamespace(StoreNamespace("a")) } + testScheduler.runCurrent() + assertTrue(gated.namespaceDeleted.isCompleted, "clear must reach atomic bulk delete") + releaseAffectedFetch.complete(Unit) + val newEngineTail = backgroundScope.async { runCatching { store.get(betweenSweeps) } } + testScheduler.runCurrent() + awaitFromDefault { betweenSweepsFetchStarted.await() } + assertEquals("b/1-v2", store.get(unrelated, Freshness.MustBeFresh)) + gated.releaseDelete.complete(Unit) + awaitFromDefault { clear.await() } + assertMissingResult(awaitFromDefault { oldTail.await() }) + assertMissingResult(awaitFromDefault { newEngineTail.await() }) + assertNull(backing.reader(affected).first()) + assertNull(backing.reader(betweenSweeps).first()) + assertEquals("b/1-v2", backing.reader(unrelated).first()) + assertLocalOnlyMissing(store, affected) + assertLocalOnlyMissing(store, betweenSweeps) + } finally { + releaseAffectedFetch.complete(Unit) + gated.releaseDelete.complete(Unit) + } + store.close() + } + + @Test + fun clearAll_fencesCommitTailsAndEngineCreatedBetweenSweeps() = runTest { + val backing = InMemorySourceOfTruth() + val gated = PostDeleteGateSourceOfTruth(backing) + val affectedFetchStarted = CompletableDeferred() + val betweenSweepsFetchStarted = CompletableDeferred() + val releaseAffectedFetch = CompletableDeferred() + val counts = mutableMapOf() + val store = store { + fetcher { key -> + val id = key.canonicalId() + val call = counts.getOrElse(id) { 0 } + 1 + counts[id] = call + if (id == "1" && call == 2) { + affectedFetchStarted.complete(Unit) + releaseAffectedFetch.await() + } + if (id == "2") betweenSweepsFetchStarted.complete(Unit) + "$id-v$call" + } + persistence(gated) + } + val existing = TestKey("1") + val betweenSweeps = TestKey("2") + store.get(existing) + val oldTail = backgroundScope.async { runCatching { store.get(existing, Freshness.MustBeFresh) } } + testScheduler.runCurrent() + + try { + awaitFromDefault { affectedFetchStarted.await() } + val clear = backgroundScope.async { store.clearAll() } + testScheduler.runCurrent() + assertTrue(gated.allDeleted.isCompleted, "clearAll must reach atomic bulk delete") + releaseAffectedFetch.complete(Unit) + val newEngineTail = backgroundScope.async { runCatching { store.get(betweenSweeps) } } + testScheduler.runCurrent() + awaitFromDefault { betweenSweepsFetchStarted.await() } + gated.releaseDelete.complete(Unit) + awaitFromDefault { clear.await() } + assertMissingResult(awaitFromDefault { oldTail.await() }) + assertMissingResult(awaitFromDefault { newEngineTail.await() }) + assertNull(backing.reader(existing).first()) + assertNull(backing.reader(betweenSweeps).first()) + assertLocalOnlyMissing(store, existing) + assertLocalOnlyMissing(store, betweenSweeps) + } finally { + releaseAffectedFetch.complete(Unit) + gated.releaseDelete.complete(Unit) + } + store.close() + } + + private fun assertMissingResult(result: Result) { + val failure = assertIs(result.exceptionOrNull()) + assertIs(failure.error) + } + + // Preserve the real-time Default-dispatch hop (never virtual time) and let the suite-level + // runTest bound own cancellation. + private suspend fun awaitFromDefault(block: suspend () -> T): T = + withContext(Dispatchers.Default) { + block() + } + + private suspend fun assertLocalOnlyMissing( + store: Store, + key: K, + ) { + val failure = assertFailsWith { store.get(key, Freshness.LocalOnly) } + assertIs(failure.error) + } +} + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreTelemetryTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreTelemetryTest.kt new file mode 100644 index 000000000..975e52f51 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreTelemetryTest.kt @@ -0,0 +1,116 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry +import org.mobilenativefoundation.store6.core.seam.runtime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration + +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class StoreTelemetryTest { + // events is mutated by 'start'/'success'/'failure' on the fetch coroutine (Dispatchers.Default) + // strictly BEFORE the FetchTicket outcome completes (the binding placement pin), and by + // 'serve'/'invalidated'/'cleared' on the caller. Every read below happens after the operation + // that resumed on that completion returned, so the list is ordered and visible -- no races. + private class RecordingTelemetry : StoreTelemetry { + val events = mutableListOf() + + override fun onFetchStarted(key: StoreKey) { + events += "start" + } + + override fun onFetchSucceeded( + key: StoreKey, + duration: Duration, + ) { + events += "success" + } + + override fun onFetchFailed( + key: StoreKey, + error: StoreError, + duration: Duration, + ) { + events += "failure" + } + + override fun onServe( + key: StoreKey, + origin: Origin, + ) { + events += "serve:$origin" + } + + override fun onInvalidated(key: StoreKey) { + events += "invalidated" + } + + override fun onCleared(key: StoreKey) { + events += "cleared" + } + } + + @Test + fun telemetry_observesFetchServeAndMaintenanceAltitude() = runTest { + val telemetry = RecordingTelemetry() + val store = store { + fetcher { "v" } + telemetry(telemetry) + } + store.get(TestKey("1")) + store.invalidate(TestKey("1")) + store.clear(TestKey("1")) + store.close() + // Deterministic BY the placement pin: success happens-before ticket completion, which + // happens-before get() resuming and calling onServe on this thread. + assertEquals( + listOf("start", "success", "serve:FETCHER", "invalidated", "cleared"), + telemetry.events, + ) + } + + @Test + fun telemetry_failureChannel() = runTest { + val telemetry = RecordingTelemetry() + val store = store { + fetcher { error("boom") } + telemetry(telemetry) + } + runCatching { store.get(TestKey("1")) } // Failed completes the ticket AFTER onFetchFailed ran. + store.close() + assertEquals(listOf("start", "failure"), telemetry.events) + } + + @Test + fun bulkClear_notifiesEachFirstSweepResidentExactlyOnce() = runTest { + val telemetry = RecordingTelemetry() + val store = store { + fetcher { key -> "v:${key.canonicalId()}" } + telemetry(telemetry) + } + store.get(TestKey("1")) + store.get(TestKey("2")) + telemetry.events.clear() + + store.clearNamespace(StoreNamespace("test")) + store.close() + + assertEquals(listOf("cleared", "cleared"), telemetry.events) + } + + @Test + fun unconfiguredTelemetry_behaviorIsUnchanged() = runTest { + val plain = store { fetcher { "v" } } + val instrumented = store { + fetcher { "v" } + telemetry(RecordingTelemetry()) + } + assertNull(plain.runtime()!!.telemetry) + assertEquals(plain.get(TestKey("1")), instrumented.get(TestKey("1"))) + plain.close() + instrumented.close() + // Allocation-count measurement lives in store6-benchmarks. + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreZeroConfigEquivalenceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreZeroConfigEquivalenceTest.kt new file mode 100644 index 000000000..5a010701f --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/StoreZeroConfigEquivalenceTest.kt @@ -0,0 +1,209 @@ +package org.mobilenativefoundation.store6.core + +import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.internal.DefaultFreshnessValidator +import org.mobilenativefoundation.store6.core.internal.InMemoryBookkeeper +import org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth +import org.mobilenativefoundation.store6.core.internal.RealStore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalStoreApi::class) +class StoreZeroConfigEquivalenceTest { + @Test + fun zeroConfig_and_expertConfig_observeIdenticalDefaults() = + runTest(timeout = 60.seconds) { + val clock = FakeWallClock(now = 1_000_000L) + val zeroConfigFetcher = ScriptedFetcher() + val zeroConfig = + store { + fetcher(zeroConfigFetcher::fetch) + wallClock(clock) + } as RealStore + + val expertConfigFetcher = ScriptedFetcher() + val expertConfig = + store { + fetcher(expertConfigFetcher::fetch) + wallClock(clock) + persistence(InMemorySourceOfTruth()) + bookkeeper(InMemoryBookkeeper()) + freshnessValidator(DefaultFreshnessValidator) + maxIdleKeys(128) + } as RealStore + + try { + val zeroConfigTrace = scenario(zeroConfig, zeroConfigFetcher) + assertEquals(EXPECTED_TRACE, zeroConfigTrace, "zero-config trace") + assertEquals(4, zeroConfigFetcher.fetchCount, "zero-config total fetches") + + val expertConfigTrace = scenario(expertConfig, expertConfigFetcher) + assertEquals(EXPECTED_TRACE, expertConfigTrace, "expert-config trace") + assertEquals(4, expertConfigFetcher.fetchCount, "expert-config total fetches") + + assertEquals(zeroConfigTrace, expertConfigTrace) + } finally { + zeroConfigFetcher.releaseSecondFetch.complete(Unit) + expertConfigFetcher.releaseSecondFetch.complete(Unit) + zeroConfig.close() + expertConfig.close() + zeroConfig.awaitTerminationForTest() + expertConfig.awaitTerminationForTest() + } + } + + @Test + fun defaultMaxIdleKeys_matchesExplicit128Cap() = + runTest(timeout = 60.seconds) { + val zeroConfig = + store { + fetcher { key -> "value:${key.canonicalId()}" } + } as RealStore + val expertConfig = + store { + fetcher { key -> "value:${key.canonicalId()}" } + maxIdleKeys(128) + } as RealStore + + try { + assertIdleCap128(zeroConfig, "zero-config") + assertIdleCap128(expertConfig, "expert-config") + } finally { + zeroConfig.close() + expertConfig.close() + zeroConfig.awaitTerminationForTest() + expertConfig.awaitTerminationForTest() + } + } + + private suspend fun scenario( + store: Store, + scriptedFetcher: ScriptedFetcher, + ): List { + val key = TestKey("ac6") + val observations = mutableListOf() + + observations += store.stream(key).take(2).toList().map(::label) + observations += "warm=${store.get(key)}" + store.invalidate(key) + observations += "after-invalidate=${store.get(key)}" + scriptedFetcher.secondFetchStarted.await() + + store.stream(key, Freshness.LocalOnly).test { + val stale = assertIs>(awaitItem()) + assertEquals(STALE_REFRESHING_LABEL, label(stale)) + observations += label(stale) + + scriptedFetcher.releaseSecondFetch.complete(Unit) + + var fresh = assertIs>(awaitItem()) + while (fresh.value == "v1:ac6") { + assertEquals(STALE_REFRESHING_LABEL, label(fresh)) + fresh = assertIs(awaitItem()) + } + assertEquals(FRESH_AFTER_INVALIDATE_LABEL, label(fresh)) + observations += label(fresh) + cancelAndIgnoreRemainingEvents() + } + assertEquals(2, scriptedFetcher.fetchCount, "post-invalidate refresh fetches") + + observations += "must-be-fresh=${store.get(key, Freshness.MustBeFresh)}" + assertEquals(3, scriptedFetcher.fetchCount, "MustBeFresh fetches") + + val missing = + assertFailsWith { + store.get(TestKey("missing"), Freshness.LocalOnly) + } + val missingError = assertIs(missing.error) + observations += "local-only=${missingError::class.simpleName}" + assertEquals(3, scriptedFetcher.fetchCount, "LocalOnly must not fetch") + + store.clear(key) + observations += "after-clear=${store.get(key)}" + assertEquals(4, scriptedFetcher.fetchCount, "clear/get fetches") + + return observations + } + + private suspend fun assertIdleCap128( + store: RealStore, + configLabel: String, + ) { + repeat(129) { index -> + val key = TestKey("idle-$index") + assertEquals("value:${key.canonicalId()}", store.get(key)) + } + + // Preserve the real-time Default-dispatch hop; the tests' explicit runTest bound owns + // cancellation. + withContext(Dispatchers.Default) { + while ( + store.createdEngineCountForTest() != 129L || + store.createdEngineCountForTest() - store.destroyedEngineCountForTest() != + store.residentEngineCountForTest().toLong() || + store.idleEngineCountForTest() != store.residentEngineCountForTest() + ) { + yield() + } + } + + assertEquals(129L, store.createdEngineCountForTest(), "$configLabel created engines") + assertEquals(128, store.residentEngineCountForTest(), "$configLabel resident engines") + assertEquals(128, store.idleEngineCountForTest(), "$configLabel idle engines") + assertEquals(1L, store.destroyedEngineCountForTest(), "$configLabel destroyed engines") + } + + private fun label(result: StoreResult): String = + when (result) { + is StoreResult.Data -> + "Data(${result.value},${result.origin},${result.isStale},${result.refreshing})" + is StoreResult.Loading -> "Loading" + is StoreResult.Revalidated -> "Revalidated" + is StoreResult.Error -> + "Error(${result.error::class.simpleName},${result.servedStale})" + } + + private class ScriptedFetcher { + val secondFetchStarted = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + var fetchCount: Int = 0 + private set + + suspend fun fetch(key: TestKey): String { + val call = ++fetchCount + if (call == 2) { + secondFetchStarted.complete(Unit) + releaseSecondFetch.await() + } + return "v$call:${key.canonicalId()}" + } + } + + private companion object { + const val STALE_REFRESHING_LABEL = "Data(v1:ac6,MEMORY,true,true)" + const val FRESH_AFTER_INVALIDATE_LABEL = "Data(v2:ac6,FETCHER,false,false)" + + val EXPECTED_TRACE = + listOf( + "Loading", + "Data(v1:ac6,FETCHER,false,false)", + "warm=v1:ac6", + "after-invalidate=v1:ac6", + STALE_REFRESHING_LABEL, + FRESH_AFTER_INVALIDATE_LABEL, + "must-be-fresh=v3:ac6", + "local-only=Missing", + "after-clear=v4:ac6", + ) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestBarriers.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestBarriers.kt new file mode 100644 index 000000000..8b11b6b1f --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestBarriers.kt @@ -0,0 +1,23 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource + +internal suspend fun awaitUntil( + timeout: Duration = 10.seconds, + condition: suspend () -> Boolean, +) { + withContext(Dispatchers.Default) { + val started = TimeSource.Monotonic.markNow() + while (!condition()) { + check(started.elapsedNow() < timeout) { + "Condition was not satisfied within $timeout." + } + delay(20) + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestClocks.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestClocks.kt new file mode 100644 index 000000000..7daff92a6 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestClocks.kt @@ -0,0 +1,41 @@ +package org.mobilenativefoundation.store6.core + +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.WallClock + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class FakeWallClock(var now: Long) : WallClock { + override fun nowEpochMillis(): Long = now +} + +@OptIn(ExperimentalStoreApi::class) +internal fun storeWith( + clock: WallClock? = null, + bookkeeper: Bookkeeper? = null, + configure: StoreBuilder.() -> Unit, +): Store = + StoreBuilder().apply { + clock?.let { this.wallClock(it) } + bookkeeper?.let { this.bookkeeper(it) } + configure() + }.build() + +/** + * Core's conformance base keeps exercising the actual zero-config persistence implementation. + * The borrowed SQLDelight compilation re-derives this support seam with a public-API equivalent. + */ +@OptIn(ExperimentalStoreApi::class) +internal fun defaultConformanceSourceOfTruth(): SourceOfTruth = + org.mobilenativefoundation.store6.core.internal.InMemorySourceOfTruth() + +/** + * Test-support shutdown: close, then JOIN the store's engine job so no engine coroutine from this + * test outlives it on Dispatchers.Default. Cleanup-only — never load-bearing for assertions. + * The borrowed-suite re-derivation in store6-sqldelight cannot reach core internals and settles + * with close() alone (documented asymmetry; see BorrowedSuiteSupport.kt). + */ +internal suspend fun Store<*, *>.closeAndSettleForTest() { + close() + (this as? org.mobilenativefoundation.store6.core.internal.RealStore<*, *>)?.awaitTerminationForTest() +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestKey.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestKey.kt new file mode 100644 index 000000000..61cc51556 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/TestKey.kt @@ -0,0 +1,34 @@ +package org.mobilenativefoundation.store6.core + +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +class TestKey(private val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("test") + override fun canonicalId(): String = id +} + +/** + * Bulk-deletion adapter for fixtures that intentionally model exactly one row in the `test` + * namespace and ignore key identity for every reader and mutation. + * + * These fixtures cannot run the source-of-truth contract kit because they do not provide key + * isolation. Their namespace/global operations delete that one modeled row through the fixture's + * existing per-key behavior, preserving any deterministic gate or fault under test. + */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal interface SingleRowTestSourceOfTruth : SourceOfTruth { + override suspend fun deleteNamespace(namespace: StoreNamespace) { + if (namespace.value == TEST_NAMESPACE) { + delete(BULK_DELETE_KEY) + } + } + + override suspend fun deleteAll() { + delete(BULK_DELETE_KEY) + } + + private companion object { + const val TEST_NAMESPACE = "test" + val BULK_DELETE_KEY = TestKey("bulk-delete") + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/docs/GuideSnippetCompilation.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/docs/GuideSnippetCompilation.kt new file mode 100644 index 000000000..a7b4b1629 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/docs/GuideSnippetCompilation.kt @@ -0,0 +1,60 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +@file:Suppress("unused") + +package org.mobilenativefoundation.store6.core.docs + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult + +private class UserKey( + val id: String, +) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} + +private data class User( + val id: String, + val name: String, +) + +private class UserApi { + suspend fun getUser(id: String): User = User(id, "User $id") +} + +private val api = UserApi() + +private val conditionalUserFetcher = + object : Fetcher { + override suspend fun fetch(key: UserKey, etag: String?): FetcherResult = + FetcherResult.Success(api.getUser(key.id), etag) + } + +private fun compileFetcherInstallPoints() { + // docs:snippet:guides-fetchers-install-points + val plainUsers = store { + fetcher { key -> api.getUser(key.id) } + } + + val resultUsers = store { + fetcherOfResult { key -> + FetcherResult.Success(api.getUser(key.id)) + } + } + + @OptIn(ExperimentalStoreApi::class) + val conditionalUsers = store { + fetcher(conditionalUserFetcher) + } + // docs:snippet:end + + plainUsers.close() + resultUsers.close() + conditionalUsers.close() +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/BookkeeperOrderingConformanceTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/BookkeeperOrderingConformanceTest.kt new file mode 100644 index 000000000..2c85d59e2 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/BookkeeperOrderingConformanceTest.kt @@ -0,0 +1,543 @@ +package org.mobilenativefoundation.store6.core.internal + +import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.FakeWallClock +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreException +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.TestKey +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.SingleRowTestSourceOfTruth +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +class BookkeeperOrderingConformanceTest { + private val key = TestKey("1") + private val keyId = KeyId.from(key) + + @Test + fun clearCannotBeOvertakenByAnOlderSuccessfulCommit() = runTest { + val successGate = MutationGate() + val bookkeeper = GateableBookkeeper(successGate = successGate) + val engine = engine(bookkeeper) { FetcherResult.Success("v1", etag = "e1") } + + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + testScheduler.runCurrent() + successGate.entered.await() + + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + testScheduler.runCurrent() + + successGate.release() + testScheduler.runCurrent() + read.await() + clear.await() + + assertNull(bookkeeper.status(key)) + } + + @Test + fun confirmFresh_successTailBlocksSameKeyClearUntilBookkeepingCompletes() = runTest { + val bookkeeper = OrderedConfirmFreshBookkeeper() + val engine = engine(bookkeeper) { FetcherResult.Success("v1", etag = "e1") } + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + assertEquals(1, bookkeeper.successCalls) + assertEquals(listOf("success1:start", "success1:end"), bookkeeper.events) + + val confirmation = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.confirmFresh(etag = "confirmed") + } + testScheduler.runCurrent() + + var clearPassedWhileSuccessBlocked = false + var eventsWhileSuccessBlocked: List = emptyList() + val clear = + try { + withTimeout(1_000L) { bookkeeper.secondSuccessStarted.await() } + assertEquals(2, bookkeeper.successCalls) + assertEquals(0, bookkeeper.forgetCalls) + + val pending = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + testScheduler.runCurrent() + clearPassedWhileSuccessBlocked = pending.isCompleted + eventsWhileSuccessBlocked = bookkeeper.events.toList() + pending + } finally { + bookkeeper.releaseSecondSuccess.complete(Unit) + } + + confirmation.await() + clear.await() + + assertFalse( + clearPassedWhileSuccessBlocked, + "same-key clear passed the blocked confirmFresh tail: $eventsWhileSuccessBlocked", + ) + assertEquals( + listOf("success1:start", "success1:end", "success2:start"), + eventsWhileSuccessBlocked, + ) + assertEquals( + listOf( + "success1:start", + "success1:end", + "success2:start", + "success2:end", + "forget:start", + "forget:end", + ), + bookkeeper.events, + ) + assertEquals(2, bookkeeper.successCalls) + assertEquals(1, bookkeeper.forgetCalls) + assertNull(bookkeeper.status(key)) + val missing = + assertFailsWith { + engine.get(Freshness.LocalOnly) + } + assertIs(missing.error) + } + + @Test + fun serverDeleteCannotEraseANewerSuccessfulCommit() = runTest { + val forgetGate = MutationGate() + val bookkeeper = GateableBookkeeper(forgetGate = forgetGate) + var calls = 0 + val engine = + engine(bookkeeper) { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.Deleted + 3 -> FetcherResult.Success("v2", etag = "e2") + else -> error("unexpected fetch call $calls") + } + } + + val seed = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.get(Freshness.CachedOrFetch) + } + testScheduler.runCurrent() + assertEquals("v1", seed.await()) + + val deletion = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + testScheduler.runCurrent() + forgetGate.entered.await() + + val replacement = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.get(Freshness.CachedOrFetch) + } + testScheduler.runCurrent() + + forgetGate.release() + testScheduler.runCurrent() + deletion.await() + assertEquals("v2", replacement.await()) + assertEquals("e2", bookkeeper.status(key)?.meta?.etag) + } + + @Test + fun failureAfterClearCannotRecreateFailureStatus() = runTest { + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val bookkeeper = GateableBookkeeper() + val engine = + engine(bookkeeper) { + fetchStarted.complete(Unit) + releaseFetch.await() + throw IllegalStateException("boom") + } + + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + testScheduler.runCurrent() + fetchStarted.await() + + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + testScheduler.runCurrent() + clear.await() + + releaseFetch.complete(Unit) + testScheduler.runCurrent() + read.await() + + assertNull(bookkeeper.status(key)) + } + + @Test + fun failureTimestampIsCapturedBeforeOrderedBookkeepingWait() = runTest { + val markGate = MutationGate() + val failureFetched = CompletableDeferred() + val bookkeeper = GateableBookkeeper(markGate = markGate) + val clock = FakeWallClock(now = 0L) + val sot = InMemorySourceOfTruth() + sot.write(key, "v1") + var calls = 0 + val engine = + engine(bookkeeper, clock, sot = sot) { + calls += 1 + failureFetched.complete(Unit) + FetcherResult.Error(IllegalStateException("boom")) + } + + assertEquals("v1", engine.get(Freshness.LocalOnly)) + assertEquals(0, calls, "LocalOnly hydration must not fetch") + + val orderedInvalidation = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.invalidate() + } + testScheduler.runCurrent() + markGate.entered.await() + + try { + clock.now = 10L + val failedRefresh = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + testScheduler.runCurrent() + failureFetched.await() + + clock.now = 99L + markGate.release() + testScheduler.runCurrent() + orderedInvalidation.await() + failedRefresh.await() + } finally { + markGate.release() + } + + assertEquals(10L, bookkeeper.status(key)?.lastFailureAtEpochMillis) + assertEquals(1, calls) + } + + @Test + fun engineCancellationReleasesOrdinaryWriteFailureBookkeeping() = runTest { + val failureGate = MutationGate() + val bookkeeper = GateableBookkeeper(failureGate = failureGate) + val sot = ThrowingWriteSourceOfTruth() + val engineJob = Job(backgroundScope.coroutineContext[Job]) + val engineScope = CoroutineScope(backgroundScope.coroutineContext + engineJob) + val engine = + engine(bookkeeper, engineScope = engineScope, sot = sot) { + FetcherResult.Success("v1", etag = "e1") + } + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + testScheduler.runCurrent() + sot.writeAttempted.await() + failureGate.entered.await() + + try { + engineJob.cancel() + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + testScheduler.runCurrent() + + assertTrue(clear.isCompleted, "engine cancellation must release writeLock") + clear.await() + } finally { + failureGate.release() + testScheduler.runCurrent() + } + read.await() + } + + @Test + fun engineCancellationReleasesBlockedFailureBookkeeping() = runTest { + val failureGate = MutationGate() + val bookkeeper = GateableBookkeeper(failureGate = failureGate) + val engineJob = Job(backgroundScope.coroutineContext[Job]) + val engineScope = CoroutineScope(backgroundScope.coroutineContext + engineJob) + val engine = + engine(bookkeeper, engineScope = engineScope) { + FetcherResult.Error(IllegalStateException("boom")) + } + val read = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.CachedOrFetch) } + } + testScheduler.runCurrent() + failureGate.entered.await() + + try { + engineJob.cancel() + val clear = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.clear() + } + testScheduler.runCurrent() + + assertTrue(clear.isCompleted, "engine cancellation must release bookkeepingLock") + clear.await() + } finally { + failureGate.release() + testScheduler.runCurrent() + } + read.await() + } + + @Test + fun invalidate_marksBeforeEpochSignal_andBlocksSameKeyCommit() = runTest { + val events = mutableListOf() + val markGate = MutationGate() + val durableBookkeeper = GateableBookkeeper(markGate = markGate, events = events) + val durableSot = InMemorySourceOfTruth() + val secondFetchEntered = CompletableDeferred() + var calls = 0 + val engine = + engine(durableBookkeeper, sot = durableSot) { + val call = ++calls + if (call == 2) secondFetchEntered.complete(Unit) + FetcherResult.Success("v$call", etag = "e$call") + } + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + val initialEpoch = engine.state.value.staleEpoch + + engine.stream(Freshness.CachedOrFetch).test { + assertEquals("v1", assertIs>(awaitItem()).value) + val invalidation = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { engine.invalidate() } + testScheduler.runCurrent() + try { + assertTrue(markGate.entered.isCompleted, "durable mark must begin before epoch signal") + assertEquals(initialEpoch, engine.state.value.staleEpoch) + expectNoEvents() + + val competingCommit = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.get(Freshness.MustBeFresh) + } + withTimeout(1_000) { secondFetchEntered.await() } + assertEquals(2, calls, "network may finish while its durable commit is fenced") + assertEquals(listOf("success"), events, "second durable commit must still be blocked") + assertEquals("v1", durableSot.reader(key).first()) + expectNoEvents() + + markGate.release() + withTimeout(1_000) { invalidation.await() } + assertEquals(initialEpoch + 1L, engine.state.value.staleEpoch) + assertEquals("v2", withTimeout(1_000) { competingCommit.await() }) + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == "v2") break + } + assertEquals(listOf("success", "markStale", "success"), events) + assertEquals(2, calls, "the ordered mark/epoch/success cycle must single-flight") + cancelAndIgnoreRemainingEvents() + } finally { + markGate.release() + } + } + } + + private fun TestScope.engine( + bookkeeper: Bookkeeper, + clock: FakeWallClock = FakeWallClock(now = 0L), + engineScope: CoroutineScope = backgroundScope, + sot: SourceOfTruth = InMemorySourceOfTruth(), + fetcher: suspend (TestKey) -> FetcherResult, + ): KeyEngine = + KeyEngine( + key = key, + keyId = keyId, + fetcher = ResultFetcher(fetcher), + sot = sot, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = clock, + engineScope = engineScope, + ) + + private class MutationGate { + val entered = CompletableDeferred() + private val released = CompletableDeferred() + + suspend fun pause() { + entered.complete(Unit) + released.await() + } + + fun release() { + released.complete(Unit) + } + } + + private class GateableBookkeeper( + successGate: MutationGate? = null, + private val failureGate: MutationGate? = null, + private val forgetGate: MutationGate? = null, + private val markGate: MutationGate? = null, + private val events: MutableList? = null, + ) : Bookkeeper { + private val delegate = InMemoryBookkeeper() + private var successGate = successGate + + fun gateNextSuccess(gate: MutationGate) { + successGate = gate + } + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + successGate?.also { successGate = null }?.pause() + events?.add("success") + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + failureGate?.pause() + delegate.recordFailure(key, atEpochMillis) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + forgetGate?.pause() + delegate.forget(key) + } + + override suspend fun markStale(key: StoreKey) { + markGate?.pause() + events?.add("markStale") + delegate.markStale(key) + } + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class OrderedConfirmFreshBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + val secondSuccessStarted = CompletableDeferred() + val releaseSecondSuccess = CompletableDeferred() + val events = mutableListOf() + var successCalls = 0 + private set + var forgetCalls = 0 + private set + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + val call = ++successCalls + events += "success$call:start" + if (call == 2) { + secondSuccessStarted.complete(Unit) + releaseSecondSuccess.await() + } + delegate.recordSuccess(key, meta) + events += "success$call:end" + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) = delegate.recordFailure(key, atEpochMillis) + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + forgetCalls += 1 + events += "forget:start" + delegate.forget(key) + events += "forget:end" + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class ThrowingWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val value = MutableStateFlow(null) + val writeAttempted = CompletableDeferred() + + override fun reader(key: TestKey): Flow = value + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeAttempted.complete(Unit) + throw IllegalStateException("write failed") + } + + override suspend fun delete(key: TestKey) { + value.value = null + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ConflateLatestDataTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ConflateLatestDataTest.kt new file mode 100644 index 000000000..836090b4b --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ConflateLatestDataTest.kt @@ -0,0 +1,365 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class ConflateLatestDataTest { + + @Test + fun slowCollector_getsLatestDataAndEveryLifecycleSignalBeforeCompletion() = runTest { + val firstDataSeen = CompletableDeferred() + val releaseCollector = CompletableDeferred() + val upstreamCompleted = CompletableDeferred() + val received = mutableListOf>() + + val upstream = flow> { + emit(data(1)) + firstDataSeen.await() + emit(data(2)) + emit(data(3)) + emit(StoreResult.Loading()) + emit(StoreResult.Loading()) + emit(StoreResult.Revalidated(age = Duration.ZERO)) + emit( + StoreResult.Error( + error = StoreError.Fetch(message = "fetch failed", cause = null), + servedStale = true, + ), + ) + upstreamCompleted.complete(Unit) + } + + val collector = backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + upstream.conflateLatestData().collect { result -> + received += result + if (result is StoreResult.Data && result.value == 1) { + firstDataSeen.complete(Unit) + releaseCollector.await() + } + } + } + + firstDataSeen.await() + upstreamCompleted.await() + assertTrue(collector.isActive) + + releaseCollector.complete(Unit) + collector.join() + + assertEquals( + listOf("data:1", "data:3", "loading", "revalidated", "error"), + received.map(::label), + ) + } + + @Test + fun blockedCollector_queueBoundedAcrossManyRevalidationCycles() = runTest { + val firstDataSeen = CompletableDeferred() + val releaseCollector = CompletableDeferred() + val upstreamCompleted = CompletableDeferred() + val received = mutableListOf>() + + val upstream = flow> { + emit(data(0)) + firstDataSeen.await() + repeat(1_000) { cycle -> + emit(data(cycle + 1)) + emit(StoreResult.Loading()) + emit( + StoreResult.Error( + error = StoreError.Fetch(message = "e$cycle", cause = null), + servedStale = false, + ), + ) + emit(StoreResult.Revalidated(age = Duration.ZERO)) + } + emit(data(9_999)) + upstreamCompleted.complete(Unit) + } + + val collector = backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + upstream.conflateLatestData().collect { result -> + received += result + if (result is StoreResult.Data && result.value == 0) { + firstDataSeen.complete(Unit) + releaseCollector.await() // park: 4_001 further emissions coalesce in the queue + } + } + } + + firstDataSeen.await() + upstreamCompleted.await() + releaseCollector.complete(Unit) + collector.join() + + // First delivered frame + at most one queued element per kind (<= 4) = <= 5 total. + assertTrue( + received.size <= 5, + "blocked collector saw ${received.size} results; bound is O(kinds)", + ) + assertEquals( + 9_999, + (received.last { it is StoreResult.Data } as StoreResult.Data).value, + ) + assertTrue(received.any { it is StoreResult.Error }) + assertTrue(received.any { it is StoreResult.Revalidated }) + assertTrue(received.any { it is StoreResult.Loading }) + } + + @Test + fun blockedCollector_removeAndAppendKeepsLatestPayloadsInRelativeOccurrenceOrder() = runTest { + val firstDataSeen = CompletableDeferred() + val releaseCollector = CompletableDeferred() + val upstreamCompleted = CompletableDeferred() + val received = mutableListOf>() + + val upstream = flow> { + emit(data(0)) + firstDataSeen.await() + emit( + StoreResult.Error( + error = StoreError.Fetch(message = "old-error", cause = null), + servedStale = false, + ), + ) + emit(StoreResult.Revalidated(age = 1.seconds)) + emit(StoreResult.Loading()) + emit( + StoreResult.Error( + error = StoreError.Fetch(message = "latest-error", cause = null), + servedStale = true, + ), + ) + emit(StoreResult.Revalidated(age = 2.seconds)) + emit(data(42)) + upstreamCompleted.complete(Unit) + } + + val collector = backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + upstream.conflateLatestData().collect { result -> + received += result + if (result is StoreResult.Data && result.value == 0) { + firstDataSeen.complete(Unit) + releaseCollector.await() + } + } + } + + firstDataSeen.await() + upstreamCompleted.await() + releaseCollector.complete(Unit) + collector.join() + + assertEquals( + listOf("data:0", "loading", "error", "revalidated", "data:42"), + received.map(::label), + ) + val latestError = received.filterIsInstance().single() + assertEquals("latest-error", (latestError.error as StoreError.Fetch).message) + assertTrue(latestError.servedStale) + assertEquals( + 2.seconds, + received.filterIsInstance().single().age, + ) + } + + @Test + fun immediateCollector_getsEverySynchronousDataEmission() = runTest { + val received = mutableListOf>() + + val upstream = flow> { + emit(data(1)) + emit(data(2)) + emit(data(3)) + } + + upstream.conflateLatestData().collect { result -> + received += result + } + + assertEquals( + listOf("data:1", "data:2", "data:3"), + received.map(::label), + ) + } + + @Test + fun eachCollector_hasIndependentConflationState() = runTest { + var subscriptions = 0 + val upstream = flow> { + subscriptions += 1 + emit(data(subscriptions)) + } + val first = mutableListOf>() + val second = mutableListOf>() + val conflated = upstream.conflateLatestData() + + conflated.collect(first::add) + conflated.collect(second::add) + + assertEquals(listOf("data:1"), first.map(::label)) + assertEquals(listOf("data:2"), second.map(::label)) + } + + @Test + fun cancellingCollector_cancelsUpstream() = runTest { + val upstreamStarted = CompletableDeferred() + val upstreamCancelled = CompletableDeferred() + val upstream = flow> { + upstreamStarted.complete(Unit) + try { + awaitCancellation() + } finally { + upstreamCancelled.complete(Unit) + } + } + + val collector = backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + upstream.conflateLatestData().collect() + } + upstreamStarted.await() + + collector.cancelAndJoin() + + upstreamCancelled.await() + } + + @Test + fun explicitUpstreamCancellation_drainsQueuedValuesThenPropagatesExactFailure() = runTest { + val firstDataSeen = CompletableDeferred() + val releaseCollector = CompletableDeferred() + val upstreamFinished = CompletableDeferred() + val explicitCancellation = CancellationException("explicit upstream cancellation") + val received = mutableListOf>() + + val collector = backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + flow> { + try { + emit(data(1)) + firstDataSeen.await() + emit(data(2)) + emit(StoreResult.Loading()) + throw explicitCancellation + } finally { + upstreamFinished.complete(Unit) + } + }.conflateLatestData().collect { result -> + received += result + if (result is StoreResult.Data && result.value == 1) { + firstDataSeen.complete(Unit) + releaseCollector.await() + } + } + }.exceptionOrNull() + } + + firstDataSeen.await() + upstreamFinished.await() + releaseCollector.complete(Unit) + + assertSame(explicitCancellation, collector.await()) + assertEquals(listOf("data:1", "data:2", "loading"), received.map(::label)) + } + + @Test + fun upstreamFailure_drainsQueuedValuesThenPropagatesExactFailure() = runTest { + val firstDataSeen = CompletableDeferred() + val releaseCollector = CompletableDeferred() + val upstreamFinished = CompletableDeferred() + val upstreamFailure = IllegalStateException("upstream failed") + val received = mutableListOf>() + + val collector = backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + flow> { + try { + emit(data(1)) + firstDataSeen.await() + emit(data(2)) + emit(StoreResult.Revalidated(age = Duration.ZERO)) + throw upstreamFailure + } finally { + upstreamFinished.complete(Unit) + } + }.conflateLatestData().collect { result -> + received += result + if (result is StoreResult.Data && result.value == 1) { + firstDataSeen.complete(Unit) + releaseCollector.await() + } + } + }.exceptionOrNull() + } + + firstDataSeen.await() + upstreamFinished.await() + releaseCollector.complete(Unit) + + assertSame(upstreamFailure, collector.await()) + assertEquals(listOf("data:1", "data:2", "revalidated"), received.map(::label)) + } + + @Test + fun downstreamCollectorFailure_cancelsUpstream() = runTest { + val upstreamStarted = CompletableDeferred() + val upstreamCancelled = CompletableDeferred() + val collectorFailure = IllegalStateException("collector failed") + val upstream = flow> { + upstreamStarted.complete(Unit) + try { + emit(data(1)) + awaitCancellation() + } finally { + upstreamCancelled.complete(Unit) + } + } + + val observedFailure = runCatching { + upstream.conflateLatestData().collect { + throw collectorFailure + } + }.exceptionOrNull() + + upstreamStarted.await() + upstreamCancelled.await() + assertTrue( + observedFailure === collectorFailure || observedFailure?.cause === collectorFailure, + "collector failure was not propagated", + ) + } + + private fun data(value: Int): StoreResult.Data = + StoreResult.Data( + value = value, + origin = Origin.MEMORY, + age = Duration.ZERO, + isStale = false, + refreshing = false, + ) + + private fun label(result: StoreResult): String = + when (result) { + is StoreResult.Data -> "data:${result.value}" + is StoreResult.Loading -> "loading" + is StoreResult.Revalidated -> "revalidated" + is StoreResult.Error -> "error" + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidatorTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidatorTest.kt new file mode 100644 index 000000000..c479a21bd --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/FreshnessValidatorTest.kt @@ -0,0 +1,334 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.seam.FetchPlan +import org.mobilenativefoundation.store6.core.seam.FreshnessContext +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +class FreshnessValidatorTest { + @Test + fun localOnly_alwaysSkipsAbsentAndStaleResident() { + assertSame( + FetchPlan.Skip, + plan( + hasResidentValue = false, + meta = null, + epochStale = false, + freshness = Freshness.LocalOnly, + ), + ) + assertSame( + FetchPlan.Skip, + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = true, + freshness = Freshness.LocalOnly, + ), + ) + } + + @Test + fun mustBeFresh_alwaysFetchesWithoutServingResident() { + assertFetch( + plan( + hasResidentValue = false, + meta = null, + epochStale = false, + freshness = Freshness.MustBeFresh, + ), + servesResident = false, + ) + assertFetch( + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = true, + freshness = Freshness.MustBeFresh, + ), + servesResident = false, + ) + } + + @Test + fun cachedOrFetch_skipsFreshFetchesStaleResidentAndFetchesAbsent() { + assertSame( + FetchPlan.Skip, + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = false, + freshness = Freshness.CachedOrFetch, + ), + ) + assertFetch( + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = true, + freshness = Freshness.CachedOrFetch, + ), + servesResident = true, + ) + assertFetch( + plan( + hasResidentValue = false, + meta = null, + epochStale = false, + freshness = Freshness.CachedOrFetch, + ), + servesResident = false, + ) + } + + @Test + fun staleIfError_usesCachedOrFetchPlanning() { + assertSame( + FetchPlan.Skip, + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = false, + freshness = Freshness.StaleIfError, + ), + ) + assertFetch( + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = true, + freshness = Freshness.StaleIfError, + ), + servesResident = true, + ) + assertFetch( + plan( + hasResidentValue = false, + meta = null, + epochStale = false, + freshness = Freshness.StaleIfError, + ), + servesResident = false, + ) + } + + @Test + fun maxAge_enforcesBoundMetadataAndEpoch() { + val freshness = Freshness.MaxAge(5.minutes) + + assertSame( + FetchPlan.Skip, + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 540_000L), + epochStale = false, + freshness = freshness, + nowEpochMillis = 600_000L, + ), + ) + assertFetch( + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = false, + freshness = freshness, + nowEpochMillis = 600_000L, + ), + servesResident = false, + ) + assertFetch( + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = Long.MIN_VALUE), + epochStale = false, + freshness = freshness, + nowEpochMillis = Long.MAX_VALUE, + ), + servesResident = false, + ) + assertFetch( + plan( + hasResidentValue = true, + meta = null, + epochStale = false, + freshness = freshness, + ), + servesResident = false, + ) + assertFetch( + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 540_000L), + epochStale = true, + freshness = freshness, + nowEpochMillis = 600_000L, + ), + servesResident = false, + ) + } + + @Test + fun maxAge_treatsNegativeClockDeltaAsFresh() { + assertSame( + FetchPlan.Skip, + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 601_000L), + epochStale = false, + freshness = Freshness.MaxAge(5.minutes), + nowEpochMillis = 600_000L, + ), + ) + assertSame( + FetchPlan.Skip, + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = Long.MAX_VALUE), + epochStale = false, + freshness = Freshness.MaxAge(Duration.ZERO), + nowEpochMillis = Long.MIN_VALUE, + ), + ) + } + + @Test + fun conditionalPlanned_whenEtagPresentAndFetchDue() { + val result = + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L, etag = "e1"), + epochStale = true, + freshness = Freshness.CachedOrFetch, + ) + + val conditional = assertIs(result) + assertEquals("e1", conditional.etag) + assertEquals(true, conditional.servesResidentWhileFetching) + } + + @Test + fun plainFetchPlanned_whenNoEtag() { + assertFetch( + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L), + epochStale = true, + freshness = Freshness.CachedOrFetch, + ), + servesResident = true, + ) + } + + @Test + fun maxAge_overBoundWithEtag_plansConditionalWithheld() { + val result = + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L, etag = "e1"), + epochStale = false, + freshness = Freshness.MaxAge(5.minutes), + nowEpochMillis = 600_000L, + ) + + val conditional = assertIs(result) + assertEquals("e1", conditional.etag) + assertEquals(false, conditional.servesResidentWhileFetching) + } + + @Test + fun durablyStale_forcesFetchUnderCachedOrFetchAndMaxAgeWithinBound() { + val status = + KeyStatus( + meta = meta(writtenAtEpochMillis = 599_000L, etag = "e1"), + lastSuccessSequence = 1L, + lastFailureAtEpochMillis = null, + consecutiveFailures = 0, + durablyStale = true, + ) + + val cached = + assertIs( + plan( + hasResidentValue = true, + meta = status.meta, + epochStale = false, + freshness = Freshness.CachedOrFetch, + nowEpochMillis = 600_000L, + status = status, + ), + ) + assertEquals(true, cached.servesResidentWhileFetching) + + val maxAge = + assertIs( + plan( + hasResidentValue = true, + meta = status.meta, + epochStale = false, + freshness = Freshness.MaxAge(5.minutes), + nowEpochMillis = 600_000L, + status = status, + ), + ) + assertEquals(false, maxAge.servesResidentWhileFetching) + } + + @Test + fun mustBeFresh_withEtagAndResident_plansConditionalWithheld() { + val result = + plan( + hasResidentValue = true, + meta = meta(writtenAtEpochMillis = 0L, etag = "e1"), + epochStale = false, + freshness = Freshness.MustBeFresh, + ) + + val conditional = assertIs(result) + assertEquals("e1", conditional.etag) + assertEquals(false, conditional.servesResidentWhileFetching) + } + + private fun plan( + hasResidentValue: Boolean, + meta: StoreMeta?, + epochStale: Boolean, + freshness: Freshness, + nowEpochMillis: Long = 0L, + status: KeyStatus? = null, + ): FetchPlan = + DefaultFreshnessValidator.plan( + FreshnessContext( + hasResidentValue = hasResidentValue, + meta = meta, + epochStale = epochStale, + freshness = freshness, + nowEpochMillis = nowEpochMillis, + status = status, + ), + ) + + private fun meta( + writtenAtEpochMillis: Long, + etag: String? = null, + ): EngineStoreMeta = + EngineStoreMeta( + writtenAtEpochMillis = writtenAtEpochMillis, + etag = etag, + ) + + private fun assertFetch( + plan: FetchPlan, + servesResident: Boolean, + ) { + assertEquals(servesResident, assertIs(plan).servesResidentWhileFetching) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperKitTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperKitTest.kt new file mode 100644 index 000000000..d92f801c7 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperKitTest.kt @@ -0,0 +1,11 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.testing.BookkeeperContractKit + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +class InMemoryBookkeeperKitTest : BookkeeperContractKit() { + override fun createBookkeeper(): Bookkeeper = InMemoryBookkeeper() +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperTest.kt new file mode 100644 index 000000000..c79c74ac6 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemoryBookkeeperTest.kt @@ -0,0 +1,402 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.NamespacedTestKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class InMemoryBookkeeperTest { + private val keyA = NamespacedTestKey(ns = "test", id = "a") + private val keyB = NamespacedTestKey(ns = "test", id = "b") + private val keyOtherNamespace = NamespacedTestKey(ns = "other", id = "a") + + @Test + fun successSequence_isMonotoneAcrossKeys() = runTest { + val bookkeeper = InMemoryBookkeeper() + + bookkeeper.recordSuccess(keyA, EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "a")) + bookkeeper.recordSuccess(keyB, EngineStoreMeta(writtenAtEpochMillis = 2L, etag = "b")) + + val first = requireNotNull(bookkeeper.status(keyA)) + val second = requireNotNull(bookkeeper.status(keyB)) + assertEquals(1L, first.lastSuccessSequence) + assertEquals(2L, second.lastSuccessSequence) + } + + @Test + fun failures_preserveSuccessAndSubsequentSuccessResetsFailures() = runTest { + val bookkeeper = InMemoryBookkeeper() + val e1 = EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "e1") + bookkeeper.recordSuccess(keyA, e1) + + bookkeeper.recordFailure(keyA, atEpochMillis = 2L) + bookkeeper.recordFailure(keyA, atEpochMillis = 3L) + + val failed = requireNotNull(bookkeeper.status(keyA)) + assertSame(e1, failed.meta) + assertEquals(1L, failed.lastSuccessSequence) + assertEquals(3L, failed.lastFailureAtEpochMillis) + assertEquals(2, failed.consecutiveFailures) + + val e2 = EngineStoreMeta(writtenAtEpochMillis = 4L, etag = "e2") + bookkeeper.recordSuccess(keyA, e2) + + val recovered = requireNotNull(bookkeeper.status(keyA)) + assertSame(e2, recovered.meta) + assertEquals(2L, recovered.lastSuccessSequence) + assertNull(recovered.lastFailureAtEpochMillis) + assertEquals(0, recovered.consecutiveFailures) + } + + @Test + fun forget_dropsRecord() = runTest { + val bookkeeper = InMemoryBookkeeper() + bookkeeper.recordSuccess(keyA, EngineStoreMeta(writtenAtEpochMillis = 1L, etag = null)) + + bookkeeper.forget(keyA) + + assertNull(bookkeeper.status(keyA)) + } + + @Test + fun markStale_isScopedToOneKey() = runTest { + val bookkeeper = InMemoryBookkeeper() + + assertNull(bookkeeper.status(keyA)) + bookkeeper.markStale(keyA) + + val marked = requireNotNull(bookkeeper.status(keyA)) + assertNull(marked.meta) + assertNull(marked.lastSuccessSequence) + assertTrue(marked.durablyStale) + assertNull(bookkeeper.status(keyB)) + } + + @Test + fun namespaceAndGlobalWatermarks_coverNeverSeenKeys() = runTest { + val bookkeeper = InMemoryBookkeeper() + + assertNull(bookkeeper.status(keyA)) + assertNull(bookkeeper.status(keyOtherNamespace)) + + bookkeeper.advanceStaleWatermark(keyA.namespace) + + assertTrue(requireNotNull(bookkeeper.status(keyA)).durablyStale) + assertTrue(requireNotNull(bookkeeper.status(keyB)).durablyStale) + assertNull(bookkeeper.status(keyOtherNamespace)) + + bookkeeper.advanceGlobalStaleWatermark() + + assertTrue(requireNotNull(bookkeeper.status(keyOtherNamespace)).durablyStale) + } + + @Test + fun failureOnlyRecord_usesZeroSuccessFloor() = runTest { + val bookkeeper = InMemoryBookkeeper() + bookkeeper.recordFailure(keyA, atEpochMillis = 10L) + + val failed = requireNotNull(bookkeeper.status(keyA)) + assertNull(failed.lastSuccessSequence) + assertFalse(failed.durablyStale) + + bookkeeper.markStale(keyA) + + val marked = requireNotNull(bookkeeper.status(keyA)) + assertNull(marked.lastSuccessSequence) + assertTrue(marked.durablyStale) + } + + @Test + fun laterSuccess_outranksKeyNamespaceAndGlobalMarks() = runTest { + val bookkeeper = InMemoryBookkeeper() + bookkeeper.recordFailure(keyA, atEpochMillis = 10L) + bookkeeper.markStale(keyA) + bookkeeper.advanceStaleWatermark(keyA.namespace) + bookkeeper.advanceGlobalStaleWatermark() + val meta = EngineStoreMeta(writtenAtEpochMillis = 20L, etag = "fresh") + + bookkeeper.recordSuccess(keyA, meta) + + val status = requireNotNull(bookkeeper.status(keyA)) + assertSame(meta, status.meta) + assertEquals(4L, status.lastSuccessSequence) + assertNull(status.lastFailureAtEpochMillis) + assertEquals(0, status.consecutiveFailures) + assertFalse(status.durablyStale) + } + + @Test + fun forgetNamespace_dropsMatchingRecordsButPreservesWatermark() = runTest { + val bookkeeper = InMemoryBookkeeper() + val matchingMeta = EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "matching") + val otherMeta = EngineStoreMeta(writtenAtEpochMillis = 2L, etag = "other") + bookkeeper.recordSuccess(keyA, matchingMeta) + bookkeeper.recordSuccess(keyOtherNamespace, otherMeta) + bookkeeper.advanceStaleWatermark(keyA.namespace) + + bookkeeper.forgetNamespace(keyA.namespace) + + val forgotten = requireNotNull(bookkeeper.status(keyA)) + assertNull(forgotten.meta) + assertNull(forgotten.lastSuccessSequence) + assertTrue(forgotten.durablyStale) + assertTrue(requireNotNull(bookkeeper.status(keyB)).durablyStale) + assertSame(otherMeta, requireNotNull(bookkeeper.status(keyOtherNamespace)).meta) + + bookkeeper.recordSuccess(keyA, matchingMeta) + + val restored = requireNotNull(bookkeeper.status(keyA)) + assertEquals(4L, restored.lastSuccessSequence) + assertFalse(restored.durablyStale) + } + + @Test + fun forgetAll_dropsRecordsButPreservesAllWatermarks() = runTest { + val bookkeeper = InMemoryBookkeeper() + val matchingMeta = EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "matching") + val otherMeta = EngineStoreMeta(writtenAtEpochMillis = 2L, etag = "other") + val neverSeen = NamespacedTestKey(ns = "never-seen", id = "key") + bookkeeper.recordSuccess(keyA, matchingMeta) + bookkeeper.recordSuccess(keyOtherNamespace, otherMeta) + bookkeeper.advanceStaleWatermark(keyA.namespace) + bookkeeper.advanceGlobalStaleWatermark() + + bookkeeper.forgetAll() + + val matching = requireNotNull(bookkeeper.status(keyA)) + val other = requireNotNull(bookkeeper.status(keyOtherNamespace)) + assertNull(matching.meta) + assertNull(other.meta) + assertTrue(matching.durablyStale) + assertTrue(other.durablyStale) + assertTrue(requireNotNull(bookkeeper.status(neverSeen)).durablyStale) + + bookkeeper.recordSuccess(keyOtherNamespace, otherMeta) + + val restored = requireNotNull(bookkeeper.status(keyOtherNamespace)) + assertEquals(5L, restored.lastSuccessSequence) + assertFalse(restored.durablyStale) + } + + @Test + fun markStale_gateFailurePublishesNeitherRecordNorSequence() = runTest { + var gateFailure: Throwable? = null + val bookkeeper = + InMemoryBookkeeper( + beforeMaintenancePublishTestGate = { gateFailure?.let { throw it } }, + ) + val meta = EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "a") + bookkeeper.recordSuccess(keyA, meta) + val before = requireNotNull(bookkeeper.status(keyA)) + gateFailure = IllegalStateException("mark publish failed") + + val failure = assertFailsWith { bookkeeper.markStale(keyA) } + + assertEquals("mark publish failed", failure.message) + assertSame(before, bookkeeper.status(keyA)) + assertFalse(before.durablyStale) + + gateFailure = null + bookkeeper.markStale(keyA) + bookkeeper.recordSuccess( + keyB, + EngineStoreMeta(writtenAtEpochMillis = 2L, etag = "b"), + ) + assertEquals(3L, requireNotNull(bookkeeper.status(keyB)).lastSuccessSequence) + } + + @Test + fun namespaceWatermark_gateFailurePublishesNeitherWatermarkNorSequence() = runTest { + var gateFailure: Throwable? = null + val bookkeeper = + InMemoryBookkeeper( + beforeMaintenancePublishTestGate = { gateFailure?.let { throw it } }, + ) + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "a"), + ) + val before = requireNotNull(bookkeeper.status(keyA)) + gateFailure = IllegalStateException("namespace watermark publish failed") + + val failure = + assertFailsWith { + bookkeeper.advanceStaleWatermark(keyA.namespace) + } + + assertEquals("namespace watermark publish failed", failure.message) + assertSame(before, bookkeeper.status(keyA)) + assertFalse(before.durablyStale) + + gateFailure = null + bookkeeper.recordSuccess( + keyB, + EngineStoreMeta(writtenAtEpochMillis = 2L, etag = "b"), + ) + assertEquals(2L, requireNotNull(bookkeeper.status(keyB)).lastSuccessSequence) + } + + @Test + fun globalWatermark_gateFailurePublishesNeitherWatermarkNorSequence() = runTest { + var gateFailure: Throwable? = null + val bookkeeper = + InMemoryBookkeeper( + beforeMaintenancePublishTestGate = { gateFailure?.let { throw it } }, + ) + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "a"), + ) + val before = requireNotNull(bookkeeper.status(keyA)) + gateFailure = IllegalStateException("global watermark publish failed") + + val failure = + assertFailsWith { + bookkeeper.advanceGlobalStaleWatermark() + } + + assertEquals("global watermark publish failed", failure.message) + assertSame(before, bookkeeper.status(keyA)) + assertNull(bookkeeper.status(keyOtherNamespace)) + + gateFailure = null + bookkeeper.recordSuccess( + keyB, + EngineStoreMeta(writtenAtEpochMillis = 2L, etag = "b"), + ) + assertEquals(2L, requireNotNull(bookkeeper.status(keyB)).lastSuccessSequence) + } + + @Test + fun forgetNamespace_gateFailureLeavesEveryRecordPublished() = runTest { + var gateFailure: Throwable? = null + val bookkeeper = + InMemoryBookkeeper( + beforeMaintenancePublishTestGate = { gateFailure?.let { throw it } }, + ) + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = "a"), + ) + bookkeeper.recordSuccess( + keyOtherNamespace, + EngineStoreMeta(writtenAtEpochMillis = 2L, etag = "other"), + ) + val matchingBefore = requireNotNull(bookkeeper.status(keyA)) + val otherBefore = requireNotNull(bookkeeper.status(keyOtherNamespace)) + gateFailure = IllegalStateException("forget publish failed") + + val failure = + assertFailsWith { + bookkeeper.forgetNamespace(keyA.namespace) + } + + assertEquals("forget publish failed", failure.message) + assertSame(matchingBefore, bookkeeper.status(keyA)) + assertSame(otherBefore, bookkeeper.status(keyOtherNamespace)) + + gateFailure = null + bookkeeper.recordSuccess( + keyB, + EngineStoreMeta(writtenAtEpochMillis = 3L, etag = "b"), + ) + assertEquals(3L, requireNotNull(bookkeeper.status(keyB)).lastSuccessSequence) + } + + @Test + fun successThenKeyMark_isDurablyStale() = runTest { + val bookkeeper = InMemoryBookkeeper() + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = null), + ) + + bookkeeper.markStale(keyA) + + assertTrue(requireNotNull(bookkeeper.status(keyA)).durablyStale) + } + + @Test + fun successThenNamespaceWatermark_isDurablyStaleAndChangesIdentityOnce() = runTest { + val bookkeeper = InMemoryBookkeeper() + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = null), + ) + val fresh = requireNotNull(bookkeeper.status(keyA)) + + bookkeeper.advanceStaleWatermark(keyOtherNamespace.namespace) + assertSame(fresh, bookkeeper.status(keyA)) + + bookkeeper.advanceStaleWatermark(keyA.namespace) + val stale = requireNotNull(bookkeeper.status(keyA)) + assertNotSame(fresh, stale) + assertTrue(stale.durablyStale) + + bookkeeper.advanceStaleWatermark(keyA.namespace) + assertSame(stale, bookkeeper.status(keyA)) + } + + @Test + fun successThenGlobalWatermark_isDurablyStale() = runTest { + val bookkeeper = InMemoryBookkeeper() + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = null), + ) + + bookkeeper.advanceGlobalStaleWatermark() + + assertTrue(requireNotNull(bookkeeper.status(keyA)).durablyStale) + } + + @Test + fun watermarkOnlyStatus_isReusedForNeverSeenAndForgottenKeys() = runTest { + val bookkeeper = InMemoryBookkeeper() + bookkeeper.advanceGlobalStaleWatermark() + val neverSeen = requireNotNull(bookkeeper.status(keyA)) + + assertSame(neverSeen, bookkeeper.status(keyOtherNamespace)) + + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = null), + ) + bookkeeper.forgetAll() + + assertSame(neverSeen, bookkeeper.status(keyA)) + } + + @Test + fun sequenceExhaustion_failsBeforeAnyMutation() = runTest { + val bookkeeper = InMemoryBookkeeper(initialSequence = Long.MAX_VALUE - 1L) + bookkeeper.recordSuccess( + keyA, + EngineStoreMeta(writtenAtEpochMillis = 1L, etag = null), + ) + val before = requireNotNull(bookkeeper.status(keyA)) + assertEquals(Long.MAX_VALUE, before.lastSuccessSequence) + + val markFailure = assertFailsWith { bookkeeper.markStale(keyA) } + assertEquals("Bookkeeper sequence exhausted", markFailure.message) + assertSame(before, bookkeeper.status(keyA)) + + val successFailure = + assertFailsWith { + bookkeeper.recordSuccess( + keyB, + EngineStoreMeta(writtenAtEpochMillis = 2L, etag = null), + ) + } + assertEquals("Bookkeeper sequence exhausted", successFailure.message) + assertNull(bookkeeper.status(keyB)) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthKitTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthKitTest.kt new file mode 100644 index 000000000..53cf39b2e --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthKitTest.kt @@ -0,0 +1,108 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.NamespacedTestKey +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.testing.SourceOfTruthContractKit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +class InMemorySourceOfTruthKitTest : SourceOfTruthContractKit() { + override fun createSourceOfTruth(): SourceOfTruth = + InMemorySourceOfTruth() + + override val keyA: NamespacedTestKey = NamespacedTestKey(ns = "primary", id = "a") + override val keyB: NamespacedTestKey = NamespacedTestKey(ns = "primary", id = "b") + override val keyOtherNamespace: NamespacedTestKey = + NamespacedTestKey(ns = "other", id = "a") + + override fun value(index: Int): String = "value-$index" +} + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +class SharedFlowSourceOfTruthKitTest : SourceOfTruthContractKit() { + override fun createSourceOfTruth(): SourceOfTruth = + SharedFlowSourceOfTruth() + + override val keyA: NamespacedTestKey = NamespacedTestKey(ns = "primary", id = "a") + override val keyB: NamespacedTestKey = NamespacedTestKey(ns = "primary", id = "b") + override val keyOtherNamespace: NamespacedTestKey = + NamespacedTestKey(ns = "other", id = "a") + + override fun value(index: Int): String = "value-$index" +} + +@OptIn( + DelicateStoreApi::class, + ExperimentalStoreApi::class, + kotlinx.coroutines.ExperimentalCoroutinesApi::class, +) +class SharedFlowSourceOfTruthLinearizationTest { + @Test + fun namespaceDeleteSerializesWriteAfterBulkReturn(): TestResult = runTest { + val keyA = NamespacedTestKey(ns = "primary", id = "a") + val keyB = NamespacedTestKey(ns = "primary", id = "b") + val keyCreatedDuringBulk = NamespacedTestKey(ns = "primary", id = "new") + val bulkEmissionStarted = CompletableDeferred() + val releaseBulkDelete = CompletableDeferred() + val sourceOfTruth = + SharedFlowSourceOfTruth { + bulkEmissionStarted.complete(Unit) + releaseBulkDelete.await() + } + sourceOfTruth.write(keyA, "a") + sourceOfTruth.write(keyB, "b") + + val bulkDelete = async { sourceOfTruth.deleteNamespace(keyA.namespace) } + var laterWrite: kotlinx.coroutines.Deferred? = null + var writeInterleaved = false + try { + bulkEmissionStarted.await() + laterWrite = async { sourceOfTruth.write(keyA, "later") } + runCurrent() + writeInterleaved = laterWrite.isCompleted + assertNull(sourceOfTruth.reader(keyCreatedDuringBulk).first()) + } finally { + releaseBulkDelete.complete(Unit) + } + + bulkDelete.await() + checkNotNull(laterWrite).await() + + assertFalse(writeInterleaved) + assertEquals("later", sourceOfTruth.reader(keyA).first()) + } + + @Test + fun bulkGateFailureLeavesEveryRowUnchanged(): TestResult = runTest { + val keyA = NamespacedTestKey(ns = "primary", id = "a") + val keyB = NamespacedTestKey(ns = "primary", id = "b") + val failure = IllegalStateException("bulk gate failed") + val sourceOfTruth = + SharedFlowSourceOfTruth { + throw failure + } + sourceOfTruth.write(keyA, "a") + sourceOfTruth.write(keyB, "b") + + val thrown = + assertFailsWith { + sourceOfTruth.deleteAll() + } + + assertEquals(failure.message, thrown.message) + assertEquals("a", sourceOfTruth.reader(keyA).first()) + assertEquals("b", sourceOfTruth.reader(keyB).first()) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthTest.kt new file mode 100644 index 000000000..564b33bec --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/InMemorySourceOfTruthTest.kt @@ -0,0 +1,50 @@ +package org.mobilenativefoundation.store6.core.internal + +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.NamespacedTestKey +import org.mobilenativefoundation.store6.core.TestKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalStoreApi::class) +class InMemorySourceOfTruthTest { + @Test + fun reader_emitsInitialRowEqualWritesAndDeleteWithoutCompleting() = runTest { + val sourceOfTruth = InMemorySourceOfTruth() + val key = TestKey("key") + + sourceOfTruth.reader(key).test { + assertNull(awaitItem()) + + sourceOfTruth.write(key, "value") + assertEquals("value", awaitItem()) + + sourceOfTruth.write(TestKey("key"), "value") + assertEquals("value", awaitItem()) + + sourceOfTruth.delete(TestKey("key")) + assertNull(awaitItem()) + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun newReader_startsWithCurrentCanonicalRowAndKeepsNamespacesIsolated() = runTest { + val sourceOfTruth = InMemorySourceOfTruth() + sourceOfTruth.write(NamespacedTestKey(ns = "first", id = "key"), "value") + + sourceOfTruth.reader(NamespacedTestKey(ns = "first", id = "key")).test { + assertEquals("value", awaitItem()) + cancelAndIgnoreRemainingEvents() + } + sourceOfTruth.reader(NamespacedTestKey(ns = "second", id = "key")).test { + assertNull(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEnginePlanningTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEnginePlanningTest.kt new file mode 100644 index 000000000..dd7ab1c6d --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyEnginePlanningTest.kt @@ -0,0 +1,5052 @@ +package org.mobilenativefoundation.store6.core.internal + +import app.cash.turbine.test +import app.cash.turbine.testIn +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.FakeWallClock +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreMeta +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.TestKey +import org.mobilenativefoundation.store6.core.seam.Bookkeeper +import org.mobilenativefoundation.store6.core.seam.FetchPlan +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.FreshnessContext +import org.mobilenativefoundation.store6.core.seam.FreshnessValidator +import org.mobilenativefoundation.store6.core.seam.KeyStatus +import org.mobilenativefoundation.store6.core.seam.Overlay +import org.mobilenativefoundation.store6.core.SingleRowTestSourceOfTruth +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes + +private val COLD_OBSERVATION_HOLD = 60.milliseconds +private val WRITER_CURRENT_DELIVERY_GAP = 1.milliseconds + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class KeyEnginePlanningTest { + @Test + fun hydration_reusesWrittenAtButStripsOldEtagAfterExternalReplacement() = runTest { + val key = TestKey("external-replacement-meta") + val durableSot = InMemorySourceOfTruth() + val durableBookkeeper = InMemoryBookkeeper() + val clock = FakeWallClock(now = 123L) + val first = + KeyEngine( + key, + KeyId.from(key), + ResultFetcher { FetcherResult.Success("engine", etag = "old-etag") }, + durableSot, + durableBookkeeper, + DefaultFreshnessValidator, + clock, + backgroundScope, + ) + assertEquals("engine", first.get(Freshness.MustBeFresh)) + durableSot.write(key, "external") + + var observed: FreshnessContext? = null + val capturingValidator = + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan { + observed = context + return FetchPlan.Skip + } + } + val restarted = + KeyEngine( + key, + KeyId.from(key), + ResultFetcher { error("LocalOnly hydration must not fetch") }, + durableSot, + durableBookkeeper, + capturingValidator, + clock, + backgroundScope, + ) + + assertEquals("external", restarted.get(Freshness.LocalOnly)) + assertEquals(123L, observed?.meta?.writtenAtEpochMillis) + assertNull(observed?.meta?.etag, "external rows must never inherit the old engine ETag") + } + + @Test + fun hydration_durablyStaleRowIsEpochStaleAndStatusVisibleToPlanning() = runTest { + val key = TestKey("durably-stale-hydration") + val durableSot = InMemorySourceOfTruth() + val durableBookkeeper = InMemoryBookkeeper() + val first = + KeyEngine( + key, + KeyId.from(key), + ResultFetcher { FetcherResult.Success("v1", etag = "e1") }, + durableSot, + durableBookkeeper, + DefaultFreshnessValidator, + FakeWallClock(now = 10L), + backgroundScope, + ) + assertEquals("v1", first.get(Freshness.MustBeFresh)) + durableBookkeeper.markStale(key) + + var observed: FreshnessContext? = null + val restarted = + KeyEngine( + key, + KeyId.from(key), + ResultFetcher { error("LocalOnly hydration must not fetch") }, + durableSot, + durableBookkeeper, + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan { + observed = context + return FetchPlan.Skip + } + }, + FakeWallClock(now = 20L), + backgroundScope, + ) + + restarted.stream(Freshness.LocalOnly).test { + val data = assertIs>(awaitItem()) + assertEquals("v1", data.value) + assertTrue(data.isStale, "durably stale hydration must be epoch-stale") + cancelAndIgnoreRemainingEvents() + } + assertEquals(true, observed?.status?.durablyStale) + } + + @Test + fun exactOwnerNotModified_emitsRevalidatedAgeAndNoThirdFetchWhileSuccessIsGated() = runTest { + val key = TestKey("owner-revalidated") + val keyId = KeyId.from(key) + val durableBookkeeper = PreDelegateSuccessGateBookkeeper() + val clock = FakeWallClock(now = 100L) + val secondFetchEntered = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key, + keyId, + ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> { + secondFetchEntered.complete(Unit) + releaseSecondFetch.await() + FetcherResult.NotModified("e1") + } + else -> error("unexpected fetch call $calls") + } + }, + InMemorySourceOfTruth(), + durableBookkeeper, + DefaultFreshnessValidator, + clock, + backgroundScope, + ) + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + engine.invalidate() + clock.now = 150L + durableBookkeeper.gateNextSuccess() + + engine.stream(Freshness.CachedOrFetch).test { + try { + val stale = assertIs>(awaitItem()) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + withTimeout(1_000) { secondFetchEntered.await() } + val ticket = assertIs(engine.state.value.fetch).ticket + val baselineRevision = assertNotNull(ticket.residenceRevisionAtLaunch) + + releaseSecondFetch.complete(Unit) + withTimeout(1_000) { durableBookkeeper.successEntered.await() } + val early = assertIs(ticket.disposition.value) + assertEquals(true, durableBookkeeper.status(key)?.durablyStale) + assertEquals(2, calls, "pre-success stale status must not manufacture a third fetch") + + durableBookkeeper.releaseSuccess.complete(Unit) + val outcome = + assertIs(withTimeout(1_000) { ticket.outcome.await() }) + assertEquals(baselineRevision + 1L, outcome.residenceRevision) + assertSame(early.envelope, outcome.envelope) + assertSame( + outcome.envelope, + assertIs(ticket.disposition.value).envelope, + ) + + var publicResult = awaitItem() + while (publicResult is StoreResult.Data && publicResult.isStale) { + publicResult = awaitItem() + } + val revalidated = assertIs(publicResult) + assertEquals(50L, revalidated.age.inWholeMilliseconds) + cancelAndIgnoreRemainingEvents() + } finally { + releaseSecondFetch.complete(Unit) + durableBookkeeper.releaseSuccess.complete(Unit) + } + } + assertEquals("v1", engine.get(Freshness.CachedOrFetch)) + assertEquals(2, calls) + } + + @Test + fun completedExactOwnerNotModified_rejectsStaleStatusSnapshotWithoutThirdFetch() = runTest { + val key = TestKey("owner-revalidated-stale-status") + val bookkeeper = PostDelegateStatusGateBookkeeper() + val secondFetchEntered = CompletableDeferred() + val releaseSecondFetch = CompletableDeferred() + val thirdFetchEntered = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = + ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> { + secondFetchEntered.complete(Unit) + releaseSecondFetch.await() + FetcherResult.NotModified("e1") + } + 3 -> { + thirdFetchEntered.complete(Unit) + FetcherResult.Success("v2", etag = "e2") + } + else -> error("unexpected fetch call $calls") + } + }, + sot = InMemorySourceOfTruth(), + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 100L), + engineScope = backgroundScope, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + engine.invalidate() + val owner = async { engine.get(Freshness.MustBeFresh) } + secondFetchEntered.await() + val ticket = assertIs(engine.state.value.fetch).ticket + + bookkeeper.gateStatusCall(number = 2) + val staleWaiter = async { engine.get(Freshness.CachedOrFetch) } + bookkeeper.statusEntered.await() + + releaseSecondFetch.complete(Unit) + assertIs(ticket.outcome.await()) + bookkeeper.releaseStatus.complete(Unit) + + assertEquals("v1", owner.await()) + assertEquals("v1", staleWaiter.await()) + testScheduler.runCurrent() + assertFalse(thirdFetchEntered.isCompleted) + assertEquals(2, calls) + assertIs(engine.state.value.fetch) + assertEquals("v2", engine.get(Freshness.MustBeFresh)) + assertTrue(thirdFetchEntered.isCompleted) + assertEquals(3, calls, "a later MustBeFresh demand must not reuse the old 304 owner") + } + + // A 304 that launched against a null residence baseline but finds residence present at + // commit is an obsolete launch snapshot, not an adapter-contract violation. It must + // classify ObsoleteRevalidation and self-heal by replanning once. + @Test + fun coldBaselineNotModified_hydratedBeforeCommit_classifiesObsoleteAndReplans() = runTest { + val key = TestKey("cold-304-hydrated") + val sourceOfTruth = InMemorySourceOfTruth() + val firstFetchEntered = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + firstFetchEntered.complete(Unit) + releaseFirstFetch.await() + FetcherResult.NotModified("e1") + } + 2 -> FetcherResult.NotModified("e1") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + engine.stream(Freshness.CachedOrFetch).test { + assertIs(awaitItem()) + firstFetchEntered.await() + val ticket = assertIs(engine.state.value.fetch).ticket + assertNull(ticket.residenceRevisionAtLaunch) + + sourceOfTruth.write(key, "external") + assertEquals("external", assertIs>(awaitItem()).value) + + releaseFirstFetch.complete(Unit) + assertIs(ticket.outcome.await()) + + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> assertEquals("external", item.value) + is StoreResult.Revalidated -> break + else -> fail("unexpected lifecycle item ${item::class.simpleName}") + } + } + testScheduler.runCurrent() + assertEquals(2, calls, "the cold-baseline 304 self-heal replans exactly once") + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun trulyColdNotModified_staysTypedMissingAdapterContractFailure() = runTest { + val key = TestKey("cold-304-empty") + val firstFetchEntered = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + firstFetchEntered.complete(Unit) + releaseFirstFetch.await() + FetcherResult.NotModified("e1") + } + else -> error("unexpected fetch call $calls") + } + }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + engine.stream(Freshness.CachedOrFetch).test { + assertIs(awaitItem()) + firstFetchEntered.await() + val ticket = assertIs(engine.state.value.fetch).ticket + assertNull(ticket.residenceRevisionAtLaunch) + + releaseFirstFetch.complete(Unit) + val failed = assertIs(ticket.outcome.await()) + assertIs(failed.exception.error) + + val failure = assertIs(awaitItem()) + assertIs(failure.error) + assertFalse(failure.servedStale) + testScheduler.runCurrent() + assertEquals(1, calls, "a truly cold 304 is terminal and must not replan") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun exactOwnerNotModified_backwardClockEmitsZeroAge() = runTest { + val key = TestKey("owner-revalidated-backward-clock") + val clock = FakeWallClock(now = 100L) + var calls = 0 + val engine = + KeyEngine( + key, + KeyId.from(key), + ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + else -> FetcherResult.NotModified("e1") + } + }, + InMemorySourceOfTruth(), + InMemoryBookkeeper(), + DefaultFreshnessValidator, + clock, + backgroundScope, + ) + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + clock.now = 50L + + engine.stream(Freshness.MustBeFresh).test { + assertIs(awaitItem()) + assertEquals(Duration.ZERO, assertIs(awaitItem()).age) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun exactOwnerNotModified_nullPreRefreshMetadataEmitsZeroAge() = runTest { + val key = TestKey("owner-revalidated-null-meta") + val sourceOfTruth = InMemorySourceOfTruth() + sourceOfTruth.write(key, "seed") + val engine = + KeyEngine( + key, + KeyId.from(key), + ResultFetcher { FetcherResult.NotModified("e1") }, + sourceOfTruth, + InMemoryBookkeeper(), + DefaultFreshnessValidator, + FakeWallClock(now = 50L), + backgroundScope, + ) + assertEquals("seed", engine.get(Freshness.LocalOnly)) + + engine.stream(Freshness.MustBeFresh).test { + assertIs(awaitItem()) + assertEquals(Duration.ZERO, assertIs(awaitItem()).age) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun exactOwnerNotModified_overflowAgeSaturatesToLongMaxMillis() = runTest { + val key = TestKey("owner-revalidated-overflow-age") + val clock = FakeWallClock(now = Long.MIN_VALUE) + var calls = 0 + val engine = + KeyEngine( + key, + KeyId.from(key), + ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + else -> FetcherResult.NotModified("e1") + } + }, + InMemorySourceOfTruth(), + InMemoryBookkeeper(), + DefaultFreshnessValidator, + clock, + backgroundScope, + ) + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + clock.now = Long.MAX_VALUE + + engine.stream(Freshness.MustBeFresh).test { + assertIs(awaitItem()) + val age = assertIs(awaitItem()).age + assertEquals(Long.MAX_VALUE, age.inWholeMilliseconds) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun staleEpochAdvancedBeforeSubscription_isReplayedAfterPlanningEpoch() = runTest { + val states = MutableStateFlow(KeyState.Initial) + val planningEpoch = states.value.staleEpoch + states.value = states.value.copy(staleEpoch = planningEpoch + 1L) + + assertEquals( + planningEpoch + 1L, + states.staleEpochsAfter(planningEpoch).first(), + ) + } + + @Test + fun mustBeFreshInvalidationDuringSuccessfulInitialFetch_isSatisfiedByCommit() = runTest { + val key = TestKey("1") + var calls = 0 + val firstStarted = CompletableDeferred() + val firstGate = CompletableDeferred() + val secondStarted = CompletableDeferred() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + firstStarted.complete(Unit) + firstGate.await() + FetcherResult.Success("v1") + } + + 2 -> { + secondStarted.complete(Unit) + FetcherResult.Success("v2") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + engine.stream(Freshness.MustBeFresh).test { + assertIs(awaitItem()) + firstStarted.await() + engine.invalidate() + firstGate.complete(Unit) + + assertEquals("v1", assertIs>(awaitItem()).value) + testScheduler.runCurrent() + assertEquals(1, calls) + assertFalse(secondStarted.isCompleted) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun mustBeFreshFastInitialCommit_doesNotMintAReplacementTicket() = runTest { + val key = TestKey("fast") + val sourceOfTruth = WriteStartedSourceOfTruth() + val gate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("v$calls") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = gate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.MustBeFresh).testIn(backgroundScope) + gate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + gate.release() + + assertIs(collector.awaitItem()) + val data = assertIs>(collector.awaitItem()) + assertEquals("v1", data.value) + assertFalse(data.refreshing) + testScheduler.runCurrent() + assertEquals(1, calls) + collector.expectNoEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun cachedFastInitialCommit_afterInitialSnapshot_emitsLoadingBeforeData() = runTest { + val key = TestKey("fast-after-snapshot") + val sourceOfTruth = WriteStartedSourceOfTruth() + val snapshotGate = InitialDeliveryGate().also { it.arm() } + val readerDeliveryGate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("v1") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + afterInitialPlanningSnapshotTestGate = snapshotGate::awaitIfArmed, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + snapshotGate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + snapshotGate.release() + + assertIs(collector.awaitItem()) + readerDeliveryGate.entered.await() + collector.expectNoEvents() + readerDeliveryGate.release() + val data = assertIs>(collector.awaitItem()) + assertEquals("v1", data.value) + assertEquals(Origin.FETCHER, data.origin) + assertFalse(data.refreshing) + testScheduler.runCurrent() + assertEquals(1, calls) + collector.expectNoEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun cachedFastInitialCommit_waitsForWriterCurrentRow() = runTest { + val key = TestKey("cached-fast") + val sourceOfTruth = WriteStartedSourceOfTruth() + val initialGate = InitialDeliveryGate().also { it.arm() } + val deliveryGate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("v1") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialGate::awaitIfArmed, + beforeReaderDeliveryTestGate = deliveryGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + initialGate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + initialGate.release() + + assertIs(collector.awaitItem()) + deliveryGate.entered.await() + collector.expectNoEvents() + deliveryGate.release() + + val data = assertIs>(collector.awaitItem()) + assertEquals("v1", data.value) + assertEquals(Origin.FETCHER, data.origin) + assertFalse(data.refreshing) + assertEquals(1, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun completedCommitObservedByInvalidate_reprocessesLatestCausalRowBeforeWatcherNoop() = + runTest { + val key = TestKey("commit-observed-by-invalidate") + val sourceOfTruth = StartupRaceSourceOfTruth() + val bookkeeper = GateSuccessBookkeeper() + val outcomeDeliveryGate = InitialDeliveryGate() + val thirdStarted = CompletableDeferred() + val releaseThird = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> FetcherResult.Success("v2") + 3 -> { + thirdStarted.complete(Unit) + releaseThird.await() + FetcherResult.Success("v3") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + bookkeeper.gateNextSuccess() + + engine.invalidate() + testScheduler.runCurrent() + assertTrue(bookkeeper.successEntered.isCompleted) + assertEquals("v2", assertIs>(collector.awaitItem()).value) + + outcomeDeliveryGate.arm() + bookkeeper.releaseSuccess.complete(Unit) + testScheduler.runCurrent() + assertTrue(outcomeDeliveryGate.entered.isCompleted) + engine.invalidate() + testScheduler.runCurrent() + + assertTrue(thirdStarted.isCompleted) + collector.expectNoEvents() + outcomeDeliveryGate.release() + releaseThird.complete(Unit) + assertEquals("v3", assertIs>(collector.awaitItem()).value) + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun completedInitialDelete_doesNotLaunchAReplacement() = runTest { + val key = TestKey("deleted-fast") + val gate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Deleted + }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = gate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + gate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + gate.release() + + assertIs(collector.awaitItem()) + assertIs(assertIs(collector.awaitItem()).error) + testScheduler.runCurrent() + assertEquals(1, calls) + collector.expectNoEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun pendingDeletedOutcome_retiresMissingBeforeNewerSatisfiedRow() = runTest { + val key = TestKey("pending-deleted-newer-row") + val sourceOfTruth = StartupRaceSourceOfTruth() + val bookkeeper = GateForgetBookkeeper() + val deleteStarted = CompletableDeferred() + val releaseDelete = CompletableDeferred() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> { + deleteStarted.complete(Unit) + releaseDelete.await() + FetcherResult.Deleted + } + + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("recovered") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + bookkeeper.gateNextForget() + engine.invalidate() + deleteStarted.await() + val deletedTicket = assertIs(engine.state.value.fetch).ticket + releaseDelete.complete(Unit) + bookkeeper.forgetEntered.await() + assertIs(deletedTicket.disposition.value) + assertFalse(deletedTicket.outcome.isCompleted) + + sourceOfTruth.publish("external") + testScheduler.runCurrent() + collector.expectNoEvents() + bookkeeper.releaseForget.complete(Unit) + + val delivered = mutableListOf>() + while (delivered.none { it is StoreResult.Data && it.value == "external" }) { + delivered += collector.awaitItem() + } + val missingIndex = + delivered.indexOfFirst { + it is StoreResult.Error && it.error is StoreError.Missing + } + val dataIndex = + delivered.indexOfFirst { + it is StoreResult.Data && it.value == "external" + } + assertTrue(delivered.any { it is StoreResult.Loading }) + assertTrue(missingIndex >= 0) + assertTrue(missingIndex < dataIndex) + assertTrue(assertIs>(delivered[dataIndex]).refreshing) + replacementStarted.await() + + releaseReplacement.complete(Unit) + assertEquals( + "recovered", + assertIs>(collector.awaitItem()).value, + ) + testScheduler.runCurrent() + collector.expectNoEvents() + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun mustNotModifiedReaderReplay_doesNotLaunchAThirdFetch() = runTest { + val key = TestKey("must-304-replay") + val sourceOfTruth = ReplayEveryRowSourceOfTruth() + val readerDeliveryGate = InitialDeliveryGate() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + engine.stream(Freshness.MustBeFresh).test { + assertIs(awaitItem()) + assertEquals(Duration.ZERO, assertIs(awaitItem()).age) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + // Cross the reader-delivery gate after the direct 304 completion so the assertion is + // causally downstream of an actual equal-value replay, not merely its upstream emit. + readerDeliveryGate.arm() + sourceOfTruth.publish("v1") + readerDeliveryGate.entered.await() + expectNoEvents() + readerDeliveryGate.release() + testScheduler.runCurrent() + assertEquals(2, calls) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun pendingNotModified_sameValueReplayWaitsForWatcherDelivery() = runTest { + val key = TestKey("pending-304-replay") + val sourceOfTruth = StartupRaceSourceOfTruth() + val bookkeeper = GateSuccessBookkeeper() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + bookkeeper.gateNextSuccess() + + engine.invalidate() + bookkeeper.successEntered.await() + sourceOfTruth.publish("v1") + testScheduler.runCurrent() + collector.expectNoEvents() + + bookkeeper.releaseSuccess.complete(Unit) + assertEquals( + Duration.ZERO, + assertIs(collector.awaitItem()).age, + ) + assertEquals(2, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun mappedReplayDeliveredBeforeCompletedNotModifiedWatcher_doesNotLaunchReplacement() = + runTest { + val key = TestKey("mapped-replay-before-304-watcher") + val sourceOfTruth = StartupRaceSourceOfTruth() + val readerDeliveryGate = InitialDeliveryGate() + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.NotModified(etag = "e1") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals( + "seed", + assertIs>(observer.awaitItem()).value, + ) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + readerDeliveryGate.arm() + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + val baseline = assertIs>(collector.awaitItem()) + assertEquals("seed", baseline.value) + assertTrue(baseline.isStale) + assertTrue(baseline.refreshing) + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + readerDeliveryGate.entered.await() + + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + collector.expectNoEvents() + readerDeliveryGate.release() + + assertEquals( + Duration.ZERO, + assertIs(collector.awaitItem()).age, + ) + assertEquals(1, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun getNotModifiedInvalidatedDuringOutcomeTail_retriesBeforeReturning() = runTest { + val key = TestKey("get-304-invalidated-tail") + val outcomeDeliveryGate = InitialDeliveryGate() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + 3 -> FetcherResult.Success("v3", etag = "e3") + else -> error("unexpected fetch call $calls") + } + }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + outcomeDeliveryGate.arm() + val result = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.get(Freshness.MustBeFresh) + } + try { + withTimeout(1_000) { outcomeDeliveryGate.entered.await() } + withTimeout(1_000) { engine.invalidate() } + outcomeDeliveryGate.release() + + assertEquals("v3", withTimeout(1_000) { result.await() }) + assertEquals(3, calls) + } finally { + outcomeDeliveryGate.release() + } + } + + @Test + fun nonOwnerFailureHandoff_cannotLaunderAnotherTicketsRevalidation() = runTest { + val key = TestKey("non-owner-304") + val sourceOfTruth = StartupRaceSourceOfTruth() + val bookkeeper = GateFailureBookkeeper() + val outcomeDeliveryGate = InitialDeliveryGate() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.Error(IllegalStateException("offline")) + 3 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + bookkeeper.gateNextFailure() + + engine.invalidate() + bookkeeper.failureEntered.await() + outcomeDeliveryGate.arm() + bookkeeper.releaseFailure.complete(Unit) + outcomeDeliveryGate.entered.await() + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + outcomeDeliveryGate.release() + assertIs(assertIs(collector.awaitItem()).error) + collector.expectNoEvents() + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun passiveInitialRecapture_keepsPre304MemoryBaseline() = runTest { + val key = TestKey("passive-initial-304") + val sourceOfTruth = StartupRaceSourceOfTruth() + val initialGate = InitialDeliveryGate().also { it.arm() } + val clock = FakeWallClock(now = 0L) + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = clock, + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + clock.now = 10.minutes.inWholeMilliseconds + app.cash.turbine.turbineScope { + val passive = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + initialGate.entered.await() + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + initialGate.release() + + val baseline = assertIs>(passive.awaitItem()) + assertEquals("v1", baseline.value) + assertEquals(10.minutes, baseline.age) + assertEquals(2, calls) + passive.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun foreignNotModifiedOwner_maxAgePlansFromVisibleBaselineAfterClockCrossing() = runTest { + val key = TestKey("foreign-304-max-age") + val sourceOfTruth = ReplayEveryRowSourceOfTruth() + val clock = FakeWallClock(now = 0L) + val bookkeeper = GateSuccessBookkeeper() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.NotModified(etag = "e3") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = clock, + engineScope = backgroundScope, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val passive = + engine.stream(Freshness.MaxAge(10.minutes)).testIn(backgroundScope) + assertEquals("v1", assertIs>(passive.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + clock.now = 9.minutes.inWholeMilliseconds + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + passive.expectNoEvents() + + clock.now = 11.minutes.inWholeMilliseconds + sourceOfTruth.publish("v1") + assertIs(passive.awaitItem()) + replacementStarted.await() + passive.expectNoEvents() + + bookkeeper.gateNextSuccess() + releaseReplacement.complete(Unit) + bookkeeper.successEntered.await() + passive.expectNoEvents() + bookkeeper.releaseSuccess.complete(Unit) + assertEquals( + 2.minutes, + assertIs(passive.awaitItem()).age, + ) + testScheduler.runCurrent() + passive.expectNoEvents() + assertEquals(3, calls) + passive.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun maxAgeLaunchWithheld_backwardClockDuringNotModifiedTail_staysLoading() = runTest { + val key = TestKey("max-age-backward-clock-304") + val sourceOfTruth = StartupRaceSourceOfTruth() + val clock = FakeWallClock(now = 0L) + val bookkeeper = GateSuccessBookkeeper() + val initialGate = InitialDeliveryGate().also { it.arm() } + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = clock, + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + clock.now = 10.minutes.inWholeMilliseconds + bookkeeper.gateNextSuccess() + app.cash.turbine.turbineScope { + val collector = + engine.stream(Freshness.MaxAge(5.minutes)).testIn(backgroundScope) + initialGate.entered.await() + bookkeeper.successEntered.await() + clock.now = 0L + initialGate.release() + + assertIs(collector.awaitItem()) + collector.expectNoEvents() + bookkeeper.releaseSuccess.complete(Unit) + + assertEquals( + 10.minutes, + assertIs(collector.awaitItem()).age, + ) + assertEquals(2, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun mappedDifferentRow_survivesForeignNotModifiedMetadataConvergence() = runTest { + val key = TestKey("mapped-row-owner-convergence") + val sourceOfTruth = StartupRaceSourceOfTruth() + val readerDeliveryGate = InitialDeliveryGate() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + FetcherResult.NotModified(etag = "e$calls") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val passive = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(passive.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + readerDeliveryGate.arm() + sourceOfTruth.publish("external") + readerDeliveryGate.entered.await() + assertEquals("external", engine.get(Freshness.MustBeFresh)) + + readerDeliveryGate.release() + val changed = assertIs>(passive.awaitItem()) + assertEquals("external", changed.value) + assertEquals(Origin.SOT, changed.origin) + passive.expectNoEvents() + assertEquals(1, calls) + passive.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun readerFirstAuthoritativeAbsent_loadsBeforeGatedFailureOutcome() = runTest { + val key = TestKey("reader-first-absent") + val sourceOfTruth = StartupRaceSourceOfTruth() + val outcomeDeliveryGate = InitialDeliveryGate() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> FetcherResult.Error(IllegalStateException("offline")) + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("fresh") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + outcomeDeliveryGate.arm() + engine.invalidate() + outcomeDeliveryGate.entered.await() + sourceOfTruth.publishAbsent() + + assertIs(collector.awaitItem()) + collector.expectNoEvents() + outcomeDeliveryGate.release() + assertIs(assertIs(collector.awaitItem()).error) + replacementStarted.await() + releaseReplacement.complete(Unit) + assertEquals("fresh", assertIs>(collector.awaitItem()).value) + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun completedFailureRetainsDemandAcrossLateEqualReplay_forCachedAndStaleIfError() = + runTest { + app.cash.turbine.turbineScope { + listOf(Freshness.CachedOrFetch, Freshness.StaleIfError).forEachIndexed { + index, + freshness, + -> + val key = TestKey("failed-late-equal-replay-$index") + val mappingGate = InitialDeliveryGate().also { it.arm() } + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + val thirdStarted = CompletableDeferred() + val failure = IllegalStateException("offline-$index") + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + FetcherResult.Error(failure) + } + + 3 -> { + thirdStarted.complete(Unit) + FetcherResult.Success("unexpected") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + engine.invalidate() + val collector = engine.stream(freshness).testIn(backgroundScope) + try { + val stale = assertIs>(collector.awaitItem()) + assertEquals("v1", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + secondStarted.await() + mappingGate.entered.await() + + releaseSecond.complete(Unit) + val error = assertIs(collector.awaitItem()) + assertTrue(error.servedStale) + assertEquals(2, calls) + + mappingGate.release() + testScheduler.runCurrent() + assertFalse( + thirdStarted.isCompleted, + "equal late replay must not launch a third fetch for $freshness", + ) + collector.expectNoEvents() + } finally { + releaseSecond.complete(Unit) + mappingGate.release() + collector.cancelAndIgnoreRemainingEvents() + } + } + } + } + + @Test + fun completedFailureRetainsDemandAcrossLateNullReplay() = runTest { + val key = TestKey("failed-late-null-replay") + val mappingGate = InitialDeliveryGate().also { it.arm() } + val firstStarted = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val secondStarted = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + firstStarted.complete(Unit) + releaseFirst.await() + FetcherResult.Error(IllegalStateException("offline")) + } + + 2 -> { + secondStarted.complete(Unit) + FetcherResult.Success("unexpected") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfArmed, + ) + + engine.stream(Freshness.CachedOrFetch).test { + assertIs(awaitItem()) + firstStarted.await() + mappingGate.entered.await() + + releaseFirst.complete(Unit) + assertIs(assertIs(awaitItem()).error) + assertEquals(1, calls) + + mappingGate.release() + testScheduler.runCurrent() + assertFalse( + secondStarted.isCompleted, + "duplicate late absence must not launch a second fetch", + ) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun outerTicketCommittedAfterInitialOutcomeCheck_emitsLoadingBeforeData() = runTest { + val key = TestKey("outer-ticket-final-classification") + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val classificationGate = InitialDeliveryGate().also { it.arm() } + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("fresh") + }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReplacementDispositionClassificationTestGate = + classificationGate::awaitIfArmed, + ) + + engine.stream(Freshness.CachedOrFetch).test { + fetchStarted.await() + classificationGate.entered.await() + releaseFetch.complete(Unit) + testScheduler.runCurrent() + classificationGate.release() + + assertIs(awaitItem()) + assertEquals("fresh", assertIs>(awaitItem()).value) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun outerTicketCommittedWithBaselineAfterInitialOutcomeCheck_emitsLoading() = runTest { + val key = TestKey("outer-ticket-committed-baseline") + val sourceOfTruth = ReplayEveryRowSourceOfTruth() + val mappingGate = InitialDeliveryGate() + val classificationGate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("fresh") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfArmed, + beforeReplacementDispositionClassificationTestGate = + classificationGate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + mappingGate.arm() + + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + classificationGate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + mappingGate.entered.await() + assertIs(ticket.outcome.await()) + classificationGate.release() + + assertIs(collector.awaitItem()) + mappingGate.release() + assertEquals("fresh", assertIs>(collector.awaitItem()).value) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun outerTicketCommittingAfterInitialOutcomeCheck_emitsServableBaseline() = runTest { + val key = TestKey("outer-ticket-committing-baseline") + val sourceOfTruth = GatedWriterReturnSourceOfTruth() + val classificationGate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("fresh") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReplacementDispositionClassificationTestGate = + classificationGate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + engine.stream(Freshness.CachedOrFetch).test { + classificationGate.entered.await() + fetchStarted.await() + releaseFetch.complete(Unit) + sourceOfTruth.writeStarted.await() + classificationGate.release() + + val baseline = assertIs>(awaitItem()) + assertEquals("seed", baseline.value) + assertTrue(baseline.isStale) + assertFalse(baseline.refreshing) + expectNoEvents() + + sourceOfTruth.releaseWrite.complete(Unit) + val committed = assertIs>(awaitItem()) + assertEquals("fresh", committed.value) + assertEquals(Origin.FETCHER, committed.origin) + assertFalse(committed.refreshing) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun outerTicketRevalidatedAfterInitialOutcomeCheck_emitsBaselineThenFreshOwner() = runTest { + val key = TestKey("outer-ticket-revalidated-baseline") + val sourceOfTruth = StartupRaceSourceOfTruth() + val classificationGate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.NotModified(etag = "e1") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReplacementDispositionClassificationTestGate = + classificationGate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + engine.stream(Freshness.CachedOrFetch).test { + classificationGate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + classificationGate.release() + + val baseline = assertIs>(awaitItem()) + assertEquals("seed", baseline.value) + assertTrue(baseline.isStale) + assertFalse(baseline.refreshing) + + assertEquals(Duration.ZERO, assertIs(awaitItem()).age) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun outerTicketRevalidatedBeforeOutcomeTail_doesNotReserveReplacement() = runTest { + val key = TestKey("outer-ticket-revalidated-pending-tail") + val sourceOfTruth = StartupRaceSourceOfTruth() + val bookkeeper = PreDelegateSuccessGateBookkeeper().also { it.gateNextSuccess() } + val classificationGate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.NotModified(etag = "e1") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReplacementDispositionClassificationTestGate = + classificationGate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + engine.invalidate() + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + try { + classificationGate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + bookkeeper.successEntered.await() + assertIs(ticket.disposition.value) + assertFalse(ticket.outcome.isCompleted) + + classificationGate.release() + val baseline = assertIs>(collector.awaitItem()) + assertEquals("seed", baseline.value) + assertTrue(baseline.isStale) + assertFalse(baseline.refreshing) + assertEquals(1, calls) + + bookkeeper.releaseSuccess.complete(Unit) + assertIs(ticket.outcome.await()) + assertEquals( + Duration.ZERO, + assertIs(collector.awaitItem()).age, + ) + assertEquals(1, calls) + collector.expectNoEvents() + collector.cancelAndIgnoreRemainingEvents() + } finally { + classificationGate.release() + releaseFetch.complete(Unit) + bookkeeper.releaseSuccess.complete(Unit) + } + } + } + + @Test + fun cachedFastNotModified_emitsBaselineThenWatcherRefresh() = runTest { + val key = TestKey("cached-fast-304") + val sourceOfTruth = StartupRaceSourceOfTruth() + val gate = InitialDeliveryGate().also { it.arm() } + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.NotModified(etag = "e1") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = gate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + gate.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + gate.release() + + val baseline = assertIs>(collector.awaitItem()) + assertEquals("seed", baseline.value) + assertEquals(Origin.SOT, baseline.origin) + assertTrue(baseline.isStale) + assertFalse(baseline.refreshing) + + assertEquals( + Duration.ZERO, + assertIs(collector.awaitItem()).age, + ) + testScheduler.runCurrent() + assertEquals(1, calls) + collector.expectNoEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun cachedFastNotModifiedInvalidatedBeforeDelivery_reservesBeforeStaleData() = runTest { + val key = TestKey("cached-fast-304-invalidated") + val sourceOfTruth = StartupRaceSourceOfTruth() + val initialGate = InitialDeliveryGate().also { it.arm() } + val revalidationStarted = CompletableDeferred() + val releaseRevalidation = CompletableDeferred() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + revalidationStarted.complete(Unit) + releaseRevalidation.await() + FetcherResult.NotModified(etag = "e1") + } + + 2 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("fresh", etag = "e2") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + initialGate.entered.await() + revalidationStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseRevalidation.complete(Unit) + assertIs(ticket.outcome.await()) + engine.invalidate() + initialGate.release() + + replacementStarted.await() + val stale = assertIs>(collector.awaitItem()) + assertEquals("seed", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + releaseReplacement.complete(Unit) + assertEquals("fresh", assertIs>(collector.awaitItem()).value) + assertEquals(2, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun mustNotModifiedInvalidatedBeforeDirectDelivery_launchesReplacement() = runTest { + val key = TestKey("must-304-invalidated") + val sourceOfTruth = StartupRaceSourceOfTruth() + val outcomeDeliveryGate = InitialDeliveryGate() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("v3", etag = "e3") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + outcomeDeliveryGate.arm() + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.MustBeFresh).testIn(backgroundScope) + try { + val loading = withTimeoutOrNull(1_000) { collector.awaitItem() } + assertIs(loading, "initial Loading was not delivered") + assertTrue( + withTimeoutOrNull(1_000) { + outcomeDeliveryGate.entered.await() + true + } == true, + "ticket outcome delivery gate was not reached", + ) + withTimeout(1_000) { engine.invalidate() } + outcomeDeliveryGate.release() + + withTimeout(1_000) { replacementStarted.await() } + collector.expectNoEvents() + releaseReplacement.complete(Unit) + assertEquals( + "v3", + assertIs>( + withTimeout(1_000) { collector.awaitItem() }, + ).value, + ) + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } finally { + outcomeDeliveryGate.release() + releaseReplacement.complete(Unit) + } + } + } + + @Test + fun mustNotModifiedInvalidatedBeforeDirectDelivery_replacementFailureIsTerminal() = + runTest { + val key = TestKey("must-304-invalidated-failure") + val sourceOfTruth = StartupRaceSourceOfTruth() + val outcomeDeliveryGate = InitialDeliveryGate() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1", etag = "e1") + 2 -> FetcherResult.NotModified(etag = "e2") + 3 -> FetcherResult.Error(IllegalStateException("offline")) + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + outcomeDeliveryGate.arm() + engine.stream(Freshness.MustBeFresh).test { + try { + val loading = withTimeoutOrNull(1_000) { awaitItem() } + assertIs(loading, "initial Loading was not delivered") + assertTrue( + withTimeoutOrNull(1_000) { + outcomeDeliveryGate.entered.await() + true + } == true, + "ticket outcome delivery gate was not reached", + ) + withTimeout(1_000) { engine.invalidate() } + outcomeDeliveryGate.release() + + assertIs( + assertIs(withTimeout(1_000) { awaitItem() }).error, + ) + awaitComplete() + assertEquals(3, calls) + } finally { + outcomeDeliveryGate.release() + } + } + } + + @Test + fun initialDeliveryGate_equalContentNewRevisionIsNeverRestampedMemory() = runTest { + val key = TestKey("memory-race") + val sourceOfTruth = StartupRaceSourceOfTruth() + val gate = InitialDeliveryGate() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { error("fetch must not run") }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = gate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + gate.arm() + + val raced = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + gate.entered.await() + sourceOfTruth.publish("other") + assertEquals("other", assertIs>(observer.awaitItem()).value) + sourceOfTruth.publish("seed") + val replacement = assertIs>(observer.awaitItem()) + assertEquals("seed", replacement.value) + assertEquals(Origin.SOT, replacement.origin) + + gate.release() + val first = assertIs>(raced.awaitItem()) + assertEquals("seed", first.value) + assertEquals(Origin.SOT, first.origin) + observer.cancelAndIgnoreRemainingEvents() + raced.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun failedInitialTicket_replansAResidenceThatChangedBeforeFinalDelivery() = runTest { + val key = TestKey("failed-race") + val sourceOfTruth = StartupRaceSourceOfTruth() + val gate = InitialDeliveryGate() + val bookkeeper = FailureSignallingBookkeeper() + val secondStarted = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Error(IllegalStateException("offline")) + 2 -> { + secondStarted.complete(Unit) + FetcherResult.Success("fresh") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = gate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + gate.arm() + + val raced = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + gate.entered.await() + bookkeeper.failureRecorded.await() + sourceOfTruth.publish("external") + assertEquals("external", assertIs>(observer.awaitItem()).value) + gate.release() + + val external = assertIs>(raced.awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + assertTrue(external.refreshing) + assertIs(assertIs(raced.awaitItem()).error) + secondStarted.await() + assertEquals("fresh", assertIs>(raced.awaitItem()).value) + assertEquals(2, calls) + observer.cancelAndIgnoreRemainingEvents() + raced.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun fastReplacementCommit_handsOffNewerRowBeforeSavedFailure() = runTest { + val key = TestKey("fast-replacement-commit") + val sourceOfTruth = StartupRaceSourceOfTruth() + val initialGate = InitialDeliveryGate() + val replacementDispositionGate = InitialDeliveryGate().also { it.arm() } + val bookkeeper = GateSuccessBookkeeper() + val recoveryStarted = CompletableDeferred() + val releaseRecovery = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Error(IllegalStateException("old failure")) + 2 -> FetcherResult.Success("replacement") + 3 -> { + recoveryStarted.complete(Unit) + releaseRecovery.await() + FetcherResult.Success("recovered") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialGate::awaitIfArmed, + beforeReplacementDispositionClassificationTestGate = + replacementDispositionGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + initialGate.arm() + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + initialGate.entered.await() + bookkeeper.failureRecorded.await() + sourceOfTruth.publish("external-1") + assertEquals( + "external-1", + assertIs>(observer.awaitItem()).value, + ) + + bookkeeper.gateNextSuccess() + initialGate.release() + replacementDispositionGate.entered.await() + bookkeeper.successEntered.await() + sourceOfTruth.publish("external-2") + while (true) { + val observed = assertIs>(observer.awaitItem()) + if (observed.value == "external-2") break + } + replacementDispositionGate.release() + collector.expectNoEvents() + + bookkeeper.releaseSuccess.complete(Unit) + val handedOff = assertIs>(collector.awaitItem()) + assertEquals("external-2", handedOff.value) + assertTrue(handedOff.refreshing) + assertIs(assertIs(collector.awaitItem()).error) + recoveryStarted.await() + + releaseRecovery.complete(Unit) + assertEquals( + "recovered", + assertIs>(collector.awaitItem()).value, + ) + assertEquals(3, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun fastReplacementNotModified_deliversBaselineThenOwnerBeforeSavedFailure() = runTest { + val key = TestKey("fast-replacement-304") + val sourceOfTruth = StartupRaceSourceOfTruth() + val initialGate = InitialDeliveryGate() + val replacementDispositionGate = InitialDeliveryGate().also { it.arm() } + val bookkeeper = GateSuccessBookkeeper() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Error(IllegalStateException("old failure")) + 2 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialGate::awaitIfArmed, + beforeReplacementDispositionClassificationTestGate = + replacementDispositionGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + initialGate.arm() + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + initialGate.entered.await() + bookkeeper.failureRecorded.await() + sourceOfTruth.publish("external") + assertEquals( + "external", + assertIs>(observer.awaitItem()).value, + ) + + bookkeeper.gateNextSuccess() + initialGate.release() + replacementDispositionGate.entered.await() + bookkeeper.successEntered.await() + replacementDispositionGate.release() + + val baseline = assertIs>(collector.awaitItem()) + assertEquals("external", baseline.value) + assertEquals(Origin.SOT, baseline.origin) + assertTrue(baseline.isStale) + collector.expectNoEvents() + + bookkeeper.releaseSuccess.complete(Unit) + assertEquals( + Duration.ZERO, + assertIs(collector.awaitItem()).age, + ) + assertIs(assertIs(collector.awaitItem()).error) + assertEquals(2, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun pendingInitialCommitTail_defersNewerRowUntilReplacementIsReserved() = runTest { + val key = TestKey("pending-initial-tail") + val sourceOfTruth = StartupRaceSourceOfTruth() + val initialGate = InitialDeliveryGate() + val bookkeeper = GateSuccessBookkeeper() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("candidate") + 2 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("fresh") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialGate::awaitIfArmed, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + bookkeeper.gateNextSuccess() + initialGate.arm() + + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + initialGate.entered.await() + bookkeeper.successEntered.await() + sourceOfTruth.publish("external") + while (true) { + val item = observer.awaitItem() + if (item is StoreResult.Data && item.value == "external") break + } + initialGate.release() + + collector.expectNoEvents() + + bookkeeper.releaseSuccess.complete(Unit) + replacementStarted.await() + val handedOff = assertIs>(collector.awaitItem()) + assertEquals("external", handedOff.value) + assertTrue(handedOff.refreshing) + releaseReplacement.complete(Unit) + assertEquals("fresh", assertIs>(collector.awaitItem()).value) + assertEquals(2, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun joinedOlderTicketFailure_replansTheCollectorsNewerResidence() = runTest { + val key = TestKey("joined-failure") + val sourceOfTruth = StartupRaceSourceOfTruth() + val deliveryGate = InitialDeliveryGate() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + FetcherResult.Error(IllegalStateException("offline")) + } + + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("recovered") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderDeliveryTestGate = deliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val owner = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + secondStarted.await() + val failedTicket = assertIs(engine.state.value.fetch).ticket + sourceOfTruth.publish("external-1") + + val external = assertIs>(collector.awaitItem()) + assertEquals("external-1", external.value) + assertTrue(external.refreshing) + deliveryGate.arm() + sourceOfTruth.publish("external-2") + deliveryGate.entered.await() + releaseSecond.complete(Unit) + + assertIs(failedTicket.outcome.await()) + assertTrue(owner.await().isFailure) + deliveryGate.release() + + val handedOff = assertIs>(collector.awaitItem()) + assertEquals("external-2", handedOff.value) + assertTrue(handedOff.refreshing) + assertIs(assertIs(collector.awaitItem()).error) + replacementStarted.await() + releaseReplacement.complete(Unit) + assertEquals( + "recovered", + assertIs>(collector.awaitItem()).value, + ) + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun postInitialMustFailure_replansANewerResidence() = runTest { + val key = TestKey("live-must-failure") + val sourceOfTruth = StartupRaceSourceOfTruth() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + val replacementStarted = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + FetcherResult.Error(IllegalStateException("offline")) + } + + 3 -> { + replacementStarted.complete(Unit) + FetcherResult.Success("recovered") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + app.cash.turbine.turbineScope { + val must = engine.stream(Freshness.MustBeFresh).testIn(backgroundScope) + assertIs(must.awaitItem()) + assertEquals("v1", assertIs>(must.awaitItem()).value) + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("v1", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + engine.invalidate() + assertIs(must.awaitItem()) + secondStarted.await() + sourceOfTruth.publish("external") + assertEquals( + "external", + assertIs>(observer.awaitItem()).value, + ) + releaseSecond.complete(Unit) + + assertIs(assertIs(must.awaitItem()).error) + replacementStarted.await() + assertEquals("recovered", assertIs>(must.awaitItem()).value) + assertEquals(3, calls) + observer.cancelAndIgnoreRemainingEvents() + must.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun replacementFailure_reportsTheStaleValueThatRemainedVisible() = runTest { + val key = TestKey("replacement-stale") + val sourceOfTruth = StartupRaceSourceOfTruth() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + FetcherResult.Error(IllegalStateException("first failure")) + } + + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Error(IllegalStateException("second failure")) + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val owner = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + secondStarted.await() + sourceOfTruth.publish("external") + val stale = assertIs>(collector.awaitItem()) + assertEquals("external", stale.value) + assertTrue(stale.isStale) + assertTrue(stale.refreshing) + + releaseSecond.complete(Unit) + assertTrue(owner.await().isFailure) + val firstFailure = assertIs(collector.awaitItem()) + assertTrue(firstFailure.servedStale) + replacementStarted.await() + releaseReplacement.complete(Unit) + + val secondFailure = assertIs(collector.awaitItem()) + assertTrue(secondFailure.servedStale) + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun pendingFailureTail_handsOffNewerRowBeforeSurfacingError() = runTest { + val key = TestKey("pending-failure-tail") + val sourceOfTruth = StartupRaceSourceOfTruth() + val bookkeeper = GateFailureBookkeeper() + val secondStarted = CompletableDeferred() + val releaseSecond = CompletableDeferred() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> { + secondStarted.complete(Unit) + releaseSecond.await() + FetcherResult.Error(IllegalStateException("offline")) + } + + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("fresh") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + bookkeeper.gateNextFailure() + + val owner = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { engine.get(Freshness.MustBeFresh) } + } + secondStarted.await() + sourceOfTruth.publish("external-1") + assertEquals( + "external-1", + assertIs>(collector.awaitItem()).value, + ) + releaseSecond.complete(Unit) + bookkeeper.failureEntered.await() + + sourceOfTruth.publish("external-2") + testScheduler.runCurrent() + collector.expectNoEvents() + bookkeeper.releaseFailure.complete(Unit) + assertTrue(owner.await().isFailure) + + val handedOff = assertIs>(collector.awaitItem()) + assertEquals("external-2", handedOff.value) + assertTrue(handedOff.refreshing) + assertIs(assertIs(collector.awaitItem()).error) + replacementStarted.await() + releaseReplacement.complete(Unit) + assertEquals("fresh", assertIs>(collector.awaitItem()).value) + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun completedFailureTail_queuedAuthoritativeAbsentLoadsBeforeSavedError() = runTest { + val key = TestKey("completed-failure-absent") + val sourceOfTruth = StartupRaceSourceOfTruth() + val bookkeeper = GateFailureBookkeeper() + val readerDeliveryGate = InitialDeliveryGate() + val replacementStarted = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("v1") + 2 -> FetcherResult.Error(IllegalStateException("offline")) + 3 -> { + replacementStarted.complete(Unit) + releaseReplacement.await() + FetcherResult.Success("fresh") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfArmed, + ) + + assertEquals("v1", engine.get(Freshness.MustBeFresh)) + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("v1", assertIs>(collector.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + bookkeeper.gateNextFailure() + + engine.invalidate() + bookkeeper.failureEntered.await() + readerDeliveryGate.arm() + sourceOfTruth.publish("queued-row") + readerDeliveryGate.entered.await() + + bookkeeper.releaseFailure.complete(Unit) + testScheduler.runCurrent() + sourceOfTruth.publishAbsent() + testScheduler.runCurrent() + assertTrue(runCatching { engine.get(Freshness.LocalOnly) }.isFailure) + readerDeliveryGate.release() + + assertIs(collector.awaitItem()) + assertIs(assertIs(collector.awaitItem()).error) + replacementStarted.await() + releaseReplacement.complete(Unit) + assertEquals("fresh", assertIs>(collector.awaitItem()).value) + assertEquals(3, calls) + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun coldPreWriteAbsent_cannotBecomePostCutoffAuthority() = runTest { + val key = TestKey("cold-pre-write-absent") + val sourceOfTruth = HeldColdObservationSourceOfTruth() + val fetchStarted = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + val releaseUnexpectedFetch = CompletableDeferred() + val outcomeDeliveryGate = InitialDeliveryGate().also { it.arm() } + val planningContexts = mutableListOf() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + fetchStarted.complete(Unit) + releaseFirstFetch.await() + FetcherResult.Success("fresh") + } + + 2 -> { + releaseUnexpectedFetch.await() + FetcherResult.Success("unexpected") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan { + planningContexts += context + return DefaultFreshnessValidator.plan(context) + } + }, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertIs(collector.awaitItem()) + sourceOfTruth.coldObservationHeld.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + + releaseFirstFetch.complete(Unit) + assertIs(ticket.outcome.await()) + outcomeDeliveryGate.entered.await() + testScheduler.advanceTimeBy( + (COLD_OBSERVATION_HOLD + WRITER_CURRENT_DELIVERY_GAP).inWholeMilliseconds, + ) + testScheduler.runCurrent() + + val data = assertIs>(collector.awaitItem()) + val committedContext = planningContexts.last { it.hasResidentValue } + assertEquals( + ColdPathContractObservation( + origin = Origin.FETCHER, + isStale = false, + refreshing = false, + hasMeta = true, + fetchCalls = 1, + ), + ColdPathContractObservation( + origin = data.origin, + isStale = data.isStale, + refreshing = data.refreshing, + hasMeta = committedContext.meta != null, + fetchCalls = calls, + ), + ) + + collector.cancelAndIgnoreRemainingEvents() + outcomeDeliveryGate.release() + releaseUnexpectedFetch.complete(Unit) + } + } + + @Test + fun coldPreWriteAbsent_twoCollectorsCannotLaunchMirrorFetch() = runTest { + val key = TestKey("cold-pre-write-absent-two-collectors") + val sourceOfTruth = HeldColdObservationSourceOfTruth(immediateReaderCalls = 2) + val fetchStarted = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + val releaseUnexpectedFetch = CompletableDeferred() + val initialDeliveryGate = SequencedGate() + val collectorAInitial = initialDeliveryGate.gateNext() + val collectorBInitial = initialDeliveryGate.gateNext() + val outcomeDeliveryGate = SequencedGate() + val collectorAOutcome = outcomeDeliveryGate.gateNext() + val collectorBOutcome = outcomeDeliveryGate.gateNext() + val planningContexts = mutableListOf() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + fetchStarted.complete(Unit) + releaseFirstFetch.await() + FetcherResult.Success("fresh") + } + + 2 -> { + releaseUnexpectedFetch.await() + FetcherResult.Success("unexpected") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan { + planningContexts += context + return DefaultFreshnessValidator.plan(context) + } + }, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeInitialDeliveryTestGate = initialDeliveryGate::awaitIfQueued, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfQueued, + ) + + app.cash.turbine.turbineScope { + val collectorA = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + val collectorB = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + collectorAInitial.entered.await() + collectorBInitial.entered.await() + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + assertEquals(1, calls) + assertFalse(sourceOfTruth.coldObservationHeld.isCompleted) + collectorAInitial.release.complete(Unit) + collectorBInitial.release.complete(Unit) + assertIs(collectorA.awaitItem()) + assertIs(collectorB.awaitItem()) + sourceOfTruth.coldObservationHeld.await() + + releaseFirstFetch.complete(Unit) + assertIs(ticket.outcome.await()) + collectorAOutcome.entered.await() + collectorBOutcome.entered.await() + testScheduler.advanceTimeBy( + (COLD_OBSERVATION_HOLD + WRITER_CURRENT_DELIVERY_GAP).inWholeMilliseconds, + ) + testScheduler.runCurrent() + + val data = + listOf( + assertIs>(collectorA.awaitItem()), + assertIs>(collectorB.awaitItem()), + ) + val committedContext = planningContexts.last { it.hasResidentValue } + data.forEach { result -> + assertEquals( + ColdPathContractObservation( + origin = Origin.FETCHER, + isStale = false, + refreshing = false, + hasMeta = true, + fetchCalls = 1, + ), + ColdPathContractObservation( + origin = result.origin, + isStale = result.isStale, + refreshing = result.refreshing, + hasMeta = committedContext.meta != null, + fetchCalls = calls, + ), + ) + } + + collectorA.cancelAndIgnoreRemainingEvents() + collectorB.cancelAndIgnoreRemainingEvents() + collectorAOutcome.release.complete(Unit) + collectorBOutcome.release.complete(Unit) + releaseUnexpectedFetch.complete(Unit) + } + } + + @Test + fun successorWrite_cannotRetireQueuedPredecessorEchoFence() = runTest { + val key = TestKey("successor-write-predecessor-echo") + val sourceOfTruth = ConsecutiveWriteEchoSourceOfTruth() + val fetchStarted = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + val outcomeDeliveryGate = InitialDeliveryGate().also { it.arm() } + val planningContexts = mutableListOf() + val unexpectedFetchStarted = CompletableDeferred() + val releaseUnexpectedFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + fetchStarted.complete(Unit) + releaseFirstFetch.await() + FetcherResult.Success("first") + } + + 2 -> { + unexpectedFetchStarted.complete(Unit) + releaseUnexpectedFetch.await() + FetcherResult.Success("unexpected") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan { + planningContexts += context + return DefaultFreshnessValidator.plan(context) + } + }, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeTicketOutcomeDeliveryTestGate = outcomeDeliveryGate::awaitIfArmed, + ) + + app.cash.turbine.turbineScope { + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertIs(collector.awaitItem()) + fetchStarted.await() + sourceOfTruth.liveReaderStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFirstFetch.complete(Unit) + sourceOfTruth.firstEchoHeld.await() + assertIs(ticket.outcome.await()) + outcomeDeliveryGate.entered.await() + + val successor = backgroundScope.async { engine.applyWrite("second") } + try { + sourceOfTruth.secondWriteStarted.await() + sourceOfTruth.releaseFirstEcho.complete(Unit) + testScheduler.runCurrent() + + val first = assertIs>(collector.awaitItem()) + val committedContext = planningContexts.last { it.hasResidentValue } + assertEquals( + ColdPathContractObservation( + origin = Origin.FETCHER, + isStale = false, + refreshing = false, + hasMeta = true, + fetchCalls = 1, + ), + ColdPathContractObservation( + origin = first.origin, + isStale = first.isStale, + refreshing = first.refreshing, + hasMeta = committedContext.meta != null, + fetchCalls = calls, + ), + ) + assertFalse(unexpectedFetchStarted.isCompleted) + } finally { + sourceOfTruth.releaseFirstEcho.complete(Unit) + sourceOfTruth.releaseSecondWriteReturn.complete(Unit) + sourceOfTruth.releaseSecondEcho.complete(Unit) + outcomeDeliveryGate.release() + releaseUnexpectedFetch.complete(Unit) + } + successor.await() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun failedSuccessorWrite_cannotStripCommittedPredecessorEnvelope() = runTest { + val key = TestKey("failed-successor-preserves-predecessor") + val sourceOfTruth = FailingSuccessorSourceOfTruth() + val mappingGate = SequencedGate() + val firstFetchStarted = CompletableDeferred() + val releaseFirstFetch = CompletableDeferred() + val unexpectedFetchStarted = CompletableDeferred() + val releaseUnexpectedFetch = CompletableDeferred() + val planningContexts = mutableListOf() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + firstFetchStarted.complete(Unit) + releaseFirstFetch.await() + FetcherResult.Success("first") + } + + 2 -> { + unexpectedFetchStarted.complete(Unit) + releaseUnexpectedFetch.await() + FetcherResult.Success("unexpected") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = + object : FreshnessValidator { + override fun plan(context: FreshnessContext): FetchPlan { + planningContexts += context + return DefaultFreshnessValidator.plan(context) + } + }, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + app.cash.turbine.turbineScope { + val owner = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + try { + assertIs(owner.awaitItem()) + firstFetchStarted.await() + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + val firstTicket = assertIs(engine.state.value.fetch).ticket + releaseFirstFetch.complete(Unit) + withTimeout(3_000L) { sourceOfTruth.firstNotificationQueued.await() } + assertIs( + withTimeout(3_000L) { firstTicket.outcome.await() }, + ) + + val successorExact = mappingGate.gateNext() + val rollback = mappingGate.gateNext() + try { + val successor = + backgroundScope.async { + runCatching { engine.applyWrite("second") } + } + withTimeout(3_000L) { successorExact.entered.await() } + sourceOfTruth.releaseRollback.complete(Unit) + withTimeout(3_000L) { sourceOfTruth.rollbackRawCaptured.await() } + + successorExact.release.complete(Unit) + withTimeout(3_000L) { rollback.entered.await() } + rollback.release.complete(Unit) + testScheduler.runCurrent() + sourceOfTruth.releaseFailure.complete(Unit) + assertTrue(withTimeout(3_000L) { successor.await() }.isFailure) + testScheduler.runCurrent() + + val retained = assertIs>(owner.awaitItem()) + val retainedContext = planningContexts.last { it.hasResidentValue } + assertEquals( + ColdPathContractObservation( + origin = Origin.FETCHER, + isStale = false, + refreshing = false, + hasMeta = true, + fetchCalls = 1, + ), + ColdPathContractObservation( + origin = retained.origin, + isStale = retained.isStale, + refreshing = retained.refreshing, + hasMeta = retainedContext.meta != null, + fetchCalls = calls, + ), + ) + assertFalse(unexpectedFetchStarted.isCompleted) + } finally { + successorExact.release.complete(Unit) + rollback.release.complete(Unit) + sourceOfTruth.releaseRollback.complete(Unit) + sourceOfTruth.releaseFailure.complete(Unit) + releaseUnexpectedFetch.complete(Unit) + } + } finally { + sourceOfTruth.releaseFirstNotification.complete(Unit) + sourceOfTruth.releaseRollback.complete(Unit) + releaseFirstFetch.complete(Unit) + releaseUnexpectedFetch.complete(Unit) + owner.cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun replacementReaderSession_retiresUnresolvedPredecessorFence() = runTest { + val key = TestKey("replacement-retires-predecessor-fence") + val sourceOfTruth = ConsecutiveWriteEchoSourceOfTruth() + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("first") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + app.cash.turbine.turbineScope { + val owner = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertIs(owner.awaitItem()) + fetchStarted.await() + sourceOfTruth.liveReaderStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + sourceOfTruth.firstEchoHeld.await() + assertIs(ticket.outcome.await()) + + owner.cancelAndIgnoreRemainingEvents() + testScheduler.advanceTimeBy(READER_PIPELINE_GRACE_MILLIS + 1L) + testScheduler.runCurrent() + + val replacement = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + sourceOfTruth.replacementReaderStarted.await() + sourceOfTruth.publishExternal("external") + var external: StoreResult.Data? = null + while (external == null) { + val item = replacement.awaitItem() + if (item is StoreResult.Data && item.value == "external") external = item + } + assertEquals(Origin.SOT, checkNotNull(external).origin) + assertEquals(1, calls) + + replacement.cancelAndIgnoreRemainingEvents() + sourceOfTruth.releaseFirstEcho.complete(Unit) + } + } + + @Test + fun olderPreStampRow_cannotOverwriteANewerWriterObservation() = runTest { + val key = TestKey("older-pre-stamp-row") + val sourceOfTruth = ReplayEveryRowSourceOfTruth() + val mappingGate = SequencedGate() + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("candidate") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val preStamp = mappingGate.gateNext() + val writerCurrent = mappingGate.gateNext() + sourceOfTruth.publish("older") + preStamp.entered.await() + + val collector = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + val stale = assertIs>(collector.awaitItem()) + assertEquals("seed", stale.value) + fetchStarted.await() + val ticket = assertIs(engine.state.value.fetch).ticket + releaseFetch.complete(Unit) + assertIs(ticket.outcome.await()) + + preStamp.release.complete(Unit) + writerCurrent.entered.await() + collector.expectNoEvents() + writerCurrent.release.complete(Unit) + + val committed = assertIs>(collector.awaitItem()) + assertEquals("candidate", committed.value) + assertEquals(Origin.FETCHER, committed.origin) + assertFalse(committed.refreshing) + assertEquals(1, calls) + observer.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun delayedExactWriterRow_cannotRegressANewerSameValueRevalidation() = runTest { + val key = TestKey("delayed-exact-after-revalidation") + val sourceOfTruth = SingleWriterCurrentSourceOfTruth() + val mappingGate = SequencedGate() + val clock = FakeWallClock(now = 0L) + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> FetcherResult.Success("fresh") + 2 -> FetcherResult.NotModified(etag = "e2") + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = clock, + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val delayedExact = mappingGate.gateNext() + val first = async { engine.get(Freshness.MustBeFresh) } + delayedExact.entered.await() + sourceOfTruth.releaseWriteReturn.complete(Unit) + assertEquals("fresh", first.await()) + + clock.now = 10.minutes.inWholeMilliseconds + engine.invalidate() + assertEquals("fresh", engine.get(Freshness.MustBeFresh)) + assertEquals(2, calls) + + delayedExact.release.complete(Unit) + testScheduler.runCurrent() + assertEquals("fresh", engine.get(Freshness.MaxAge(notOlderThan = 5.minutes))) + assertEquals(2, calls, "delayed exact row must not restore the older metadata revision") + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun delayedExactWriterRow_afterConfirmFresh_reusesCurrentEnvelopeForProjection() = runTest { + val key = TestKey("delayed-exact-after-confirm-fresh") + val sourceOfTruth = DelayedWriterEchoSourceOfTruth() + val exactRowMapped = CompletableDeferred() + var observeExactRow = false + var fetchCalls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + fetchCalls += 1 + FetcherResult.Success("unexpected") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = { + if (observeExactRow) exactRowMapped.complete(Unit) + }, + overlay = PassThroughOverlay, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + engine.applyWrite("echo") + engine.confirmFresh(etag = "confirmed") + sourceOfTruth.writerEchoHeld.await() + + val delayed = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + observer.awaitItem() + } + observeExactRow = true + sourceOfTruth.releaseWriterEcho.complete(Unit) + exactRowMapped.await() + testScheduler.runCurrent() + + assertTrue( + delayed.isCompleted, + "the delayed exact row must authorize the metadata-refreshed projection", + ) + val echo = assertIs>(delayed.await()) + assertEquals("echo", echo.value) + assertEquals(Origin.SOT, echo.origin) + assertEquals("echo", engine.get(Freshness.MaxAge(notOlderThan = 5.minutes))) + assertEquals(0, fetchCalls) + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun activeExactWriterRow_afterConfirmFresh_reusesCurrentEnvelopeForProjection() = runTest { + val key = TestKey("active-exact-after-confirm-fresh") + val sourceOfTruth = SingleWriterCurrentSourceOfTruth() + val mappingGate = SequencedGate() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + error("write-handle acknowledgement must not fetch") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + overlay = PassThroughOverlay, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val activeExact = mappingGate.gateNext() + val apply = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.applyWrite("echo") + } + activeExact.entered.await() + sourceOfTruth.writerCurrentPublished.await() + + sourceOfTruth.releaseWriteReturn.complete(Unit) + apply.await() + engine.confirmFresh(etag = "confirmed") + testScheduler.runCurrent() + + val delayed = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + observer.awaitItem() + } + activeExact.release.complete(Unit) + testScheduler.runCurrent() + + assertTrue( + delayed.isCompleted, + "the active exact row must authorize the metadata-refreshed projection", + ) + val echo = assertIs>(delayed.await()) + assertEquals("echo", echo.value) + assertEquals(Origin.SOT, echo.origin) + assertFalse(echo.isStale) + assertFalse(echo.refreshing) + assertEquals("echo", engine.get(Freshness.MaxAge(notOlderThan = 5.minutes))) + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun activeExactRowMappedBeforeConfirmFresh_overlayRetireRevealsConfirmedValue() = runTest { + val key = TestKey("active-exact-mapped-before-confirm") + val sourceOfTruth = SingleWriterCurrentSourceOfTruth() + val mappingGate = SequencedGate() + val readerDeliveryGate = SequencedGate() + val projectionDeliveryGate = SequencedGate() + val overlay = RetiringTestOverlay() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + error("write-handle acknowledgement must not fetch") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfQueued, + overlay = overlay, + afterProjectionDeliveryTestGate = projectionDeliveryGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + testScheduler.runCurrent() + val initial = assertIs>(observer.expectMostRecentItem()) + assertEquals("optimistic", initial.value) + assertEquals(Origin.OVERLAY, initial.origin) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val activeExact = mappingGate.gateNext() + val apply = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.applyWrite("echo") + } + activeExact.entered.await() + sourceOfTruth.writerCurrentPublished.await() + sourceOfTruth.releaseWriteReturn.complete(Unit) + apply.await() + + val exactDelivery = readerDeliveryGate.gateNext() + activeExact.release.complete(Unit) + testScheduler.runCurrent() + assertTrue( + exactDelivery.entered.isCompleted, + "the exact R1 row must reach collector delivery before confirmFresh", + ) + exactDelivery.release.complete(Unit) + testScheduler.runCurrent() + observer.expectNoEvents() + + val confirmDelivery = projectionDeliveryGate.gateNext() + engine.confirmFresh(etag = "confirmed") + testScheduler.runCurrent() + assertTrue(confirmDelivery.entered.isCompleted) + observer.expectNoEvents() + confirmDelivery.release.complete(Unit) + testScheduler.runCurrent() + assertEquals("echo", engine.get(Freshness.MaxAge(notOlderThan = 5.minutes))) + + val retireDelivery = projectionDeliveryGate.gateNext() + overlay.retire(key) + testScheduler.runCurrent() + assertTrue(retireDelivery.entered.isCompleted, "retirement projection was processed") + + val confirmed = assertIs>(observer.expectMostRecentItem()) + assertEquals("echo", confirmed.value) + assertEquals(Origin.SOT, confirmed.origin) + assertFalse(confirmed.isStale) + assertFalse(confirmed.refreshing) + retireDelivery.release.complete(Unit) + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun activeExactRowMappedAfterConfirmFresh_pendingOverlayRetireRevealsConfirmedValue() = runTest { + val key = TestKey("active-exact-mapped-after-confirm") + val sourceOfTruth = SingleWriterCurrentSourceOfTruth() + val mappingGate = SequencedGate() + val readerDeliveryGate = SequencedGate() + val projectionDeliveryGate = SequencedGate() + val overlay = RetiringTestOverlay() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + error("write-handle acknowledgement must not fetch") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfQueued, + overlay = overlay, + afterProjectionDeliveryTestGate = projectionDeliveryGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + testScheduler.runCurrent() + val initial = assertIs>(observer.expectMostRecentItem()) + assertEquals("optimistic", initial.value) + assertEquals(Origin.OVERLAY, initial.origin) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val activeExact = mappingGate.gateNext() + val apply = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + engine.applyWrite("echo") + } + activeExact.entered.await() + sourceOfTruth.writerCurrentPublished.await() + sourceOfTruth.releaseWriteReturn.complete(Unit) + apply.await() + + // This is the existing F-0 side of the cross-product: metadata advances first. + engine.confirmFresh(etag = "confirmed") + testScheduler.runCurrent() + observer.expectNoEvents() + + val exactDelivery = readerDeliveryGate.gateNext() + activeExact.release.complete(Unit) + testScheduler.runCurrent() + assertTrue( + exactDelivery.entered.isCompleted, + "the F-0 row must reach collector delivery after confirmFresh", + ) + exactDelivery.release.complete(Unit) + testScheduler.runCurrent() + observer.expectNoEvents() + + val retireDelivery = projectionDeliveryGate.gateNext() + overlay.retire(key) + testScheduler.runCurrent() + assertTrue(retireDelivery.entered.isCompleted, "retirement projection was processed") + val confirmed = assertIs>(observer.expectMostRecentItem()) + assertEquals("echo", confirmed.value) + assertEquals(Origin.SOT, confirmed.origin) + assertFalse(confirmed.isStale) + assertFalse(confirmed.refreshing) + retireDelivery.release.complete(Unit) + assertEquals("echo", engine.get(Freshness.MaxAge(notOlderThan = 5.minutes))) + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun committedFence_withoutWriterEcho_restartsReaderForCurrentAuthority() = runTest { + val key = TestKey("committed-fence-without-writer-echo") + val sourceOfTruth = ConflatedWriterEchoSourceOfTruth() + val fencedRowMapped = CompletableDeferred() + var observeFencedRow = false + var fetchCalls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + fetchCalls += 1 + FetcherResult.Success("unexpected") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = { + if (observeFencedRow) fencedRowMapped.complete(Unit) + }, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderHeld.await() + testScheduler.runCurrent() + val readerCallsBeforeRecovery = sourceOfTruth.readerCalls + + engine.applyWrite("candidate") + sourceOfTruth.publishExternal("external") + val delayed = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + observer.awaitItem() + } + observeFencedRow = true + sourceOfTruth.releaseLiveReader.complete(Unit) + fencedRowMapped.await() + testScheduler.runCurrent() + + assertTrue( + sourceOfTruth.readerCalls > readerCallsBeforeRecovery, + "a fenced mismatch must replace the reader before accepting current authority", + ) + assertTrue( + delayed.isCompleted, + "the replacement reader must deliver its causally post-return current row", + ) + val external = assertIs>(delayed.await()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + assertEquals(0, fetchCalls) + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun writerCurrentRaw_survivesGraceCancellationAfterMismatchedActiveRowMaps() = runTest { + val key = TestKey("writer-current-after-grace") + val sourceOfTruth = InterleavedWriteSourceOfTruth() + val mappingGate = SequencedGate() + val releaseFetch = CompletableDeferred() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + releaseFetch.await() + FetcherResult.Success("fresh") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val mismatchedRow = mappingGate.gateNext() + val writerCurrentRow = mappingGate.gateNext() + val owner = + engine.stream(Freshness.MaxAge(notOlderThan = 5.minutes)) + .testIn(backgroundScope) + assertIs(owner.awaitItem()) + releaseFetch.complete(Unit) + + mismatchedRow.entered.await() + val ticket = checkNotNull(engine.state.value.attribution).owner + mismatchedRow.release.complete(Unit) + val mismatched = assertIs>(observer.awaitItem()) + assertEquals("mismatched", mismatched.value) + assertEquals(Origin.SOT, mismatched.origin) + + sourceOfTruth.releaseWriterCurrent.complete(Unit) + writerCurrentRow.entered.await() + sourceOfTruth.writerCurrentPublished.await() + owner.cancelAndIgnoreRemainingEvents() + observer.cancelAndIgnoreRemainingEvents() + testScheduler.advanceTimeBy(READER_PIPELINE_GRACE_MILLIS + 1L) + testScheduler.runCurrent() + + sourceOfTruth.releaseWriteReturn.complete(Unit) + ticket.disposition.first { it is FetchDisposition.Committed } + assertEquals("fresh", engine.get(Freshness.LocalOnly)) + writerCurrentRow.release.complete(Unit) + } + } + + @Test + fun matchingThenOtherThenFinalMatching_convergesOnlyTheFinalWriterRow() = runTest { + val key = TestKey("matching-other-final-matching") + val sourceOfTruth = MatchingOtherMatchingSourceOfTruth() + val mappingGate = SequencedGate() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + calls += 1 + FetcherResult.Success("fresh") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val firstMatching = mappingGate.gateNext() + val owner = + engine.stream(Freshness.MaxAge(notOlderThan = 5.minutes)) + .testIn(backgroundScope) + assertIs(owner.awaitItem()) + firstMatching.entered.await() + sourceOfTruth.finalMatchingPublished.await() + val ticket = checkNotNull(engine.state.value.attribution).owner + assertEquals("seed", engine.get(Freshness.LocalOnly)) + + sourceOfTruth.releaseWriteReturn.complete(Unit) + ticket.disposition.first { it is FetchDisposition.Committed } + assertEquals("fresh", engine.get(Freshness.LocalOnly)) + firstMatching.release.complete(Unit) + + val committed = assertIs>(owner.awaitItem()) + assertEquals("fresh", committed.value) + assertEquals(Origin.FETCHER, committed.origin) + val observed = assertIs>(observer.awaitItem()) + assertEquals("fresh", observed.value) + assertEquals(Origin.FETCHER, observed.origin) + owner.expectNoEvents() + observer.expectNoEvents() + assertEquals(1, calls) + owner.cancelAndIgnoreRemainingEvents() + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun postCutoffExternalRow_winsWhileTheCommitOutcomeTailIsStillPending() = runTest { + val key = TestKey("post-cutoff-external-row") + val sourceOfTruth = SingleWriterCurrentSourceOfTruth() + val bookkeeper = GateSuccessBookkeeper() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("fresh") }, + sot = sourceOfTruth, + bookkeeper = bookkeeper, + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + bookkeeper.gateNextSuccess() + + val owner = async { engine.get(Freshness.CachedOrFetch) } + sourceOfTruth.writerCurrentPublished.await() + sourceOfTruth.releaseWriteReturn.complete(Unit) + bookkeeper.successEntered.await() + + sourceOfTruth.publishExternal("external") + var external: StoreResult.Data? = null + while (external == null) { + val item = observer.awaitItem() + if (item is StoreResult.Data && item.value == "external") external = item + } + assertEquals(Origin.SOT, checkNotNull(external).origin) + assertEquals("external", engine.get(Freshness.LocalOnly)) + + bookkeeper.releaseSuccess.complete(Unit) + owner.await() + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun clearReaderGeneration_dropsAGatedOlderRawRow() = runTest { + val key = TestKey("clear-drops-old-raw") + val sourceOfTruth = StartupRaceSourceOfTruth() + val mappingGate = SequencedGate() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("unused") }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val oldRow = mappingGate.gateNext() + sourceOfTruth.publish("old") + oldRow.entered.await() + engine.clear() + oldRow.release.complete(Unit) + testScheduler.runCurrent() + assertTrue(runCatching { engine.get(Freshness.LocalOnly) }.isFailure) + + sourceOfTruth.publish("new") + var delivered: StoreResult.Data? = null + while (delivered == null) { + val item = observer.awaitItem() + if (item is StoreResult.Data) { + assertEquals("new", item.value) + delivered = item + } + } + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun failedActiveWriterRow_isDroppedAndReaderPipelineRecovers() = runTest { + val key = TestKey("failed-active-writer-row") + val sourceOfTruth = FailingWriterSourceOfTruth() + val mappingGate = SequencedGate() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("fresh") }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val writerCurrentRow = mappingGate.gateNext() + val owner = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("seed", assertIs>(owner.awaitItem()).value) + writerCurrentRow.entered.await() + val ticket = checkNotNull(engine.state.value.attribution).owner + writerCurrentRow.release.complete(Unit) + sourceOfTruth.releaseFailure.complete(Unit) + ticket.disposition.first { it == FetchDisposition.Failed } + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + observer.expectNoEvents() + sourceOfTruth.publishExternal("external") + val external = assertIs>(observer.awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + owner.cancelAndIgnoreRemainingEvents() + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun failedWrite_postMatchExternalRow_resumesAsSotAfterTerminalCleanup() = runTest { + val key = TestKey("failed-write-post-match-external") + val sourceOfTruth = FailingAfterPostMatchExternalSourceOfTruth() + val mappingGate = SequencedGate() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("fresh") }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val matchingRow = mappingGate.gateNext() + val postMatchExternalRow = mappingGate.gateNext() + val owner = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("seed", assertIs>(owner.awaitItem()).value) + matchingRow.entered.await() + val ticket = checkNotNull(engine.state.value.attribution).owner + + sourceOfTruth.releaseExternal.complete(Unit) + sourceOfTruth.externalPublished.await() + matchingRow.release.complete(Unit) + postMatchExternalRow.entered.await() + postMatchExternalRow.release.complete(Unit) + testScheduler.runCurrent() + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + observer.expectNoEvents() + + sourceOfTruth.releaseFailure.complete(Unit) + ticket.disposition.first { it == FetchDisposition.Failed } + val external = assertIs>(observer.awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + assertEquals("external", engine.get(Freshness.LocalOnly)) + owner.cancelAndIgnoreRemainingEvents() + observer.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun activeWriterCurrentRow_restoresProvenanceOnlyAfterDurableReturn() = runTest { + val key = TestKey("active-writer-current-row") + val sourceOfTruth = InterleavedWriteSourceOfTruth() + val mappingGate = SequencedGate() + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var calls = 0 + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + when (++calls) { + 1 -> { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("fresh") + } + + else -> error("unexpected fetch call $calls") + } + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val mismatchedRow = mappingGate.gateNext() + val writerCurrentRow = mappingGate.gateNext() + val collector = + engine.stream(Freshness.MaxAge(notOlderThan = 5.minutes)) + .testIn(backgroundScope) + + assertIs(collector.awaitItem()) + fetchStarted.await() + releaseFetch.complete(Unit) + + mismatchedRow.entered.await() + mismatchedRow.release.complete(Unit) + testScheduler.runCurrent() + assertEquals(null, engine.state.value.attribution) + collector.expectNoEvents() + val mismatched = assertIs>(observer.awaitItem()) + assertEquals("mismatched", mismatched.value) + assertEquals(Origin.SOT, mismatched.origin) + + sourceOfTruth.releaseWriterCurrent.complete(Unit) + writerCurrentRow.entered.await() + sourceOfTruth.writerCurrentPublished.await() + writerCurrentRow.release.complete(Unit) + testScheduler.runCurrent() + collector.expectNoEvents() + assertEquals("mismatched", engine.get(Freshness.LocalOnly)) + observer.expectNoEvents() + + val passive = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + val heldBaseline = assertIs>(passive.awaitItem()) + assertEquals("mismatched", heldBaseline.value) + passive.expectNoEvents() + + sourceOfTruth.releaseWriteReturn.complete(Unit) + val committed = assertIs>(collector.awaitItem()) + assertEquals("fresh", committed.value) + assertEquals(Origin.FETCHER, committed.origin) + assertFalse(committed.isStale) + assertFalse(committed.refreshing) + assertEquals("fresh", assertIs>(observer.awaitItem()).value) + assertEquals("fresh", assertIs>(passive.awaitItem()).value) + assertEquals("fresh", engine.get(Freshness.LocalOnly)) + assertEquals(1, calls) + observer.cancelAndIgnoreRemainingEvents() + passive.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun matchingWriterCurrentRow_staysOutsideResidenceUntilDurableReturn() = runTest { + val key = TestKey("matching-writer-current-row") + val sourceOfTruth = SingleWriterCurrentSourceOfTruth() + val mappingGate = SequencedGate() + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + fetchStarted.complete(Unit) + releaseFetch.await() + FetcherResult.Success("fresh") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val writerCurrentRow = mappingGate.gateNext() + val collector = + engine.stream(Freshness.MaxAge(notOlderThan = 5.minutes)) + .testIn(backgroundScope) + assertIs(collector.awaitItem()) + fetchStarted.await() + releaseFetch.complete(Unit) + + writerCurrentRow.entered.await() + sourceOfTruth.writerCurrentPublished.await() + writerCurrentRow.release.complete(Unit) + testScheduler.runCurrent() + assertEquals(null, engine.state.value.attribution) + assertEquals("seed", engine.get(Freshness.LocalOnly)) + observer.expectNoEvents() + collector.expectNoEvents() + + val passive = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(passive.awaitItem()).value) + passive.expectNoEvents() + + sourceOfTruth.releaseWriteReturn.complete(Unit) + assertEquals("fresh", assertIs>(collector.awaitItem()).value) + assertEquals("fresh", assertIs>(observer.awaitItem()).value) + assertEquals("fresh", assertIs>(passive.awaitItem()).value) + assertEquals("fresh", engine.get(Freshness.LocalOnly)) + observer.cancelAndIgnoreRemainingEvents() + passive.cancelAndIgnoreRemainingEvents() + collector.cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun cancelledActiveWriterRow_isDroppedAndReaderPipelineRecovers() = runTest { + val key = TestKey("cancelled-active-writer-row") + val sourceOfTruth = CancellingWriterSourceOfTruth() + val mappingGate = SequencedGate() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("fresh") }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + ) + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + app.cash.turbine.turbineScope { + val observer = engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("seed", assertIs>(observer.awaitItem()).value) + sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + + val writerCurrentRow = mappingGate.gateNext() + val owner = engine.stream(Freshness.CachedOrFetch).testIn(backgroundScope) + assertEquals("seed", assertIs>(owner.awaitItem()).value) + writerCurrentRow.entered.await() + sourceOfTruth.writerCurrentPublished.await() + val ticket = checkNotNull(engine.state.value.attribution).owner + writerCurrentRow.release.complete(Unit) + testScheduler.runCurrent() + + assertEquals("seed", engine.get(Freshness.LocalOnly)) + observer.expectNoEvents() + sourceOfTruth.releaseCancellation.complete(Unit) + ticket.disposition.first { it == FetchDisposition.Cancelled } + testScheduler.runCurrent() + assertEquals("seed", engine.get(Freshness.LocalOnly)) + observer.expectNoEvents() + owner.cancelAndIgnoreRemainingEvents() + + sourceOfTruth.publishExternal("external") + val external = assertIs>(observer.awaitItem()) + assertEquals("external", external.value) + assertEquals(Origin.SOT, external.origin) + observer.cancelAndIgnoreRemainingEvents() + } + } + + private class InitialDeliveryGate { + private var armed = false + val entered = CompletableDeferred() + private val released = CompletableDeferred() + + fun arm() { + armed = true + } + + suspend fun awaitIfArmed() { + if (!armed) return + armed = false + entered.complete(Unit) + released.await() + } + + fun release() { + released.complete(Unit) + } + } + + private class SequencedGate { + private val queued = ArrayDeque() + + fun gateNext(): Step = Step().also(queued::addLast) + + suspend fun awaitIfQueued() { + val step = queued.removeFirstOrNull() ?: return + step.entered.complete(Unit) + step.release.await() + } + + class Step { + val entered = CompletableDeferred() + val release = CompletableDeferred() + } + } + + private data class ColdPathContractObservation( + val origin: Origin, + val isStale: Boolean, + val refreshing: Boolean, + val hasMeta: Boolean, + val fetchCalls: Int, + ) + + private class HeldColdObservationSourceOfTruth( + private val immediateReaderCalls: Int = 1, + ) : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + private var readerCalls = 0 + val coldObservationHeld = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls <= immediateReaderCalls) return rows + return flow { + var first = true + var heldColdObservation = false + rows.collect { value -> + if (first && value == null) { + first = false + heldColdObservation = true + coldObservationHeld.complete(Unit) + delay(COLD_OBSERVATION_HOLD) + } else if (heldColdObservation) { + heldColdObservation = false + delay(WRITER_CURRENT_DELIVERY_GAP) + } + emit(value) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class ConsecutiveWriteEchoSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow(extraBufferCapacity = 2) + private var readerCalls = 0 + private var current: String? = null + val liveReaderStarted = CompletableDeferred() + val replacementReaderStarted = CompletableDeferred() + val firstEchoHeld = CompletableDeferred() + val releaseFirstEcho = CompletableDeferred() + val secondWriteStarted = CompletableDeferred() + val releaseSecondWriteReturn = CompletableDeferred() + val releaseSecondEcho = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + val readerCall = ++readerCalls + val isLiveReader = readerCall >= 2 + return flow { + emit(current) + if (!isLiveReader) return@flow + liveReaderStarted.complete(Unit) + if (readerCall >= 3) replacementReaderStarted.complete(Unit) + liveRows.collect { value -> + when (value) { + "first" -> { + firstEchoHeld.complete(Unit) + releaseFirstEcho.await() + } + + "second" -> releaseSecondEcho.await() + } + emit(value) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + current = value + check(liveRows.tryEmit(value)) + if (value == "second") { + secondWriteStarted.complete(Unit) + releaseSecondWriteReturn.await() + } + } + + override suspend fun delete(key: TestKey) { + current = null + check(liveRows.tryEmit(null)) + } + + fun publishExternal(value: String) { + current = value + check(liveRows.tryEmit(value)) + } + } + + private class FailingSuccessorSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + val firstNotificationQueued = CompletableDeferred() + val releaseFirstNotification = CompletableDeferred() + val secondRawCaptured = CompletableDeferred() + val releaseRollback = CompletableDeferred() + val rollbackRawCaptured = CompletableDeferred() + val releaseFailure = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + val readerCall = ++readerCalls + if (readerCall == 1) return flow { emit(rows.value) } + return flow { + emit(rows.value) + liveReaderStarted.complete(Unit) + emitAll( + rows.drop(1).transformLatest { value -> + if (value == "first" && !firstNotificationQueued.isCompleted) { + firstNotificationQueued.complete(Unit) + releaseFirstNotification.await() + } else { + emit(value) + when (value) { + "second" -> secondRawCaptured.complete(Unit) + "first" -> rollbackRawCaptured.complete(Unit) + } + } + }, + ) + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + if (value == "first") { + firstNotificationQueued.await() + return + } + + check(value == "second") + secondRawCaptured.await() + releaseRollback.await() + rows.value = "first" + rollbackRawCaptured.await() + releaseFailure.await() + throw IllegalStateException("successor write failed") + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class ReplayEveryRowSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableSharedFlow(replay = 1) + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + + init { + rows.tryEmit("seed") + } + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + if (readerCalls >= 2) liveReaderStarted.complete(Unit) + return rows + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + suspend fun publish(value: String) { + rows.emit(value) + } + } + + private class InterleavedWriteSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow() + private var readerCalls = 0 + private var current: String? = "seed" + val liveReaderStarted = CompletableDeferred() + val writerCurrentPublished = CompletableDeferred() + val releaseWriterCurrent = CompletableDeferred() + val releaseWriteReturn = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + if (isLiveReader) liveReaderStarted.complete(Unit) + emit(current) + if (isLiveReader) liveRows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + current = "mismatched" + liveRows.emit("mismatched") + releaseWriterCurrent.await() + current = value + liveRows.emit(value) + writerCurrentPublished.complete(Unit) + releaseWriteReturn.await() + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + } + + private class SingleWriterCurrentSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow() + private var readerCalls = 0 + private var current: String? = "seed" + val liveReaderStarted = CompletableDeferred() + val writerCurrentPublished = CompletableDeferred() + val releaseWriteReturn = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + if (isLiveReader) liveReaderStarted.complete(Unit) + emit(current) + if (isLiveReader) liveRows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + current = value + liveRows.emit(value) + writerCurrentPublished.complete(Unit) + releaseWriteReturn.await() + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + + suspend fun publishExternal(value: String) { + current = value + liveRows.emit(value) + } + } + + private class DelayedWriterEchoSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = + MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ).also { check(it.tryEmit("seed")) } + var readerCalls: Int = 0 + private set + val liveReaderStarted = CompletableDeferred() + val writerEchoHeld = CompletableDeferred() + val releaseWriterEcho = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + rows.collect { value -> + if (isLiveReader && value == "echo" && !writerEchoHeld.isCompleted) { + writerEchoHeld.complete(Unit) + releaseWriterEcho.await() + } + emit(value) + if (isLiveReader) liveReaderStarted.complete(Unit) + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + } + + private class ConflatedWriterEchoSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = + MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ).also { check(it.tryEmit("seed")) } + var readerCalls: Int = 0 + private set + val liveReaderHeld = CompletableDeferred() + val releaseLiveReader = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + rows.collect { value -> + emit(value) + if (isLiveReader && !liveReaderHeld.isCompleted) { + liveReaderHeld.complete(Unit) + releaseLiveReader.await() + } + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + suspend fun publishExternal(value: String) { + rows.emit(value) + } + } + + private object PassThroughOverlay : Overlay { + override fun apply( + key: TestKey, + base: String?, + ): String? = base + + override val changes: Flow = emptyFlow() + } + + private class RetiringTestOverlay : Overlay { + private val signals = MutableSharedFlow(replay = 1) + private var pending = true + + override fun apply( + key: TestKey, + base: String?, + ): String? = if (pending) "optimistic" else base + + override val changes: Flow = signals + + suspend fun retire(key: TestKey) { + pending = false + signals.emit(key) + } + } + + private class CancellingWriterSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow() + private var readerCalls = 0 + private var current: String? = "seed" + val liveReaderStarted = CompletableDeferred() + val writerCurrentPublished = CompletableDeferred() + val releaseCancellation = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + if (isLiveReader) liveReaderStarted.complete(Unit) + emit(current) + if (isLiveReader) liveRows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + val previous = current + current = value + liveRows.emit(value) + writerCurrentPublished.complete(Unit) + releaseCancellation.await() + current = previous + liveRows.emit(previous) + throw kotlinx.coroutines.CancellationException("cancelled write") + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + + suspend fun publishExternal(value: String) { + current = value + liveRows.emit(value) + } + } + + private class MatchingOtherMatchingSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow() + private var readerCalls = 0 + private var current: String? = "seed" + val liveReaderStarted = CompletableDeferred() + val finalMatchingPublished = CompletableDeferred() + val releaseWriteReturn = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + if (isLiveReader) liveReaderStarted.complete(Unit) + emit(current) + if (isLiveReader) liveRows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + current = value + liveRows.emit(value) + current = "intermediate" + liveRows.emit("intermediate") + current = value + liveRows.emit(value) + finalMatchingPublished.complete(Unit) + releaseWriteReturn.await() + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + } + + private class FailingWriterSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow() + private var readerCalls = 0 + private var current: String? = "seed" + val liveReaderStarted = CompletableDeferred() + val releaseFailure = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + if (isLiveReader) liveReaderStarted.complete(Unit) + emit(current) + if (isLiveReader) liveRows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + val previous = current + current = value + liveRows.emit(value) + releaseFailure.await() + current = previous + liveRows.emit(previous) + throw IllegalStateException("write failed") + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + + suspend fun publishExternal(value: String) { + current = value + liveRows.emit(value) + } + } + + private class FailingAfterPostMatchExternalSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow() + private var readerCalls = 0 + private var current: String? = "seed" + val liveReaderStarted = CompletableDeferred() + val releaseExternal = CompletableDeferred() + val externalPublished = CompletableDeferred() + val releaseFailure = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + if (isLiveReader) liveReaderStarted.complete(Unit) + emit(current) + if (isLiveReader) liveRows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + current = value + liveRows.emit(value) + releaseExternal.await() + current = "external" + liveRows.emit(current) + externalPublished.complete(Unit) + releaseFailure.await() + throw IllegalStateException("write failed") + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + } + + private class StartupRaceSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + private var readerCalls = 0 + val liveReaderStarted = CompletableDeferred() + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val call = readerCalls + return flow { + if (call >= 2) liveReaderStarted.complete(Unit) + rows.collect { emit(it) } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + + fun publish(value: String) { + rows.value = value + } + + fun publishAbsent() { + rows.value = null + } + } + + private class WriteStartedSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow(null) + val writeStarted = CompletableDeferred() + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeStarted.complete(Unit) + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class GatedWriterReturnSourceOfTruth : SingleRowTestSourceOfTruth { + private val rows = MutableStateFlow("seed") + val writeStarted = CompletableDeferred() + val releaseWrite = CompletableDeferred() + + override fun reader(key: TestKey): Flow = rows + + override suspend fun write( + key: TestKey, + value: String, + ) { + writeStarted.complete(Unit) + releaseWrite.await() + rows.value = value + } + + override suspend fun delete(key: TestKey) { + rows.value = null + } + } + + private class FailureSignallingBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + val failureRecorded = CompletableDeferred() + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + delegate.recordFailure(key, atEpochMillis) + failureRecorded.complete(Unit) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + delegate.forget(key) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class GateSuccessBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + private var gateNext = false + val successEntered = CompletableDeferred() + val releaseSuccess = CompletableDeferred() + val failureRecorded = CompletableDeferred() + + fun gateNextSuccess() { + gateNext = true + } + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + delegate.recordSuccess(key, meta) + if (gateNext) { + gateNext = false + successEntered.complete(Unit) + releaseSuccess.await() + } + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + delegate.recordFailure(key, atEpochMillis) + failureRecorded.complete(Unit) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + delegate.forget(key) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + /** Pauses the selected success before it can clear durable staleness in the delegate. */ + private class PreDelegateSuccessGateBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + private var gateNext = false + val successEntered = CompletableDeferred() + val releaseSuccess = CompletableDeferred() + + fun gateNextSuccess() { + gateNext = true + } + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + if (gateNext) { + gateNext = false + successEntered.complete(Unit) + releaseSuccess.await() + } + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) = delegate.recordFailure(key, atEpochMillis) + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) = delegate.forget(key) + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + /** Pauses one selected status call after it has captured the delegate's immutable snapshot. */ + private class PostDelegateStatusGateBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + private val statusCallsUntilGate = MutableStateFlow(0) + val statusEntered = CompletableDeferred() + val releaseStatus = CompletableDeferred() + + fun gateStatusCall(number: Int) { + require(number > 0) { "number must be positive" } + check(statusCallsUntilGate.compareAndSet(expect = 0, update = number)) { + "a status gate is already armed" + } + } + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) = delegate.recordSuccess(key, meta) + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) = delegate.recordFailure(key, atEpochMillis) + + override suspend fun status(key: StoreKey): KeyStatus? { + val captured = delegate.status(key) + while (true) { + val remaining = statusCallsUntilGate.value + if (remaining == 0) return captured + if (statusCallsUntilGate.compareAndSet(remaining, remaining - 1)) { + if (remaining == 1) { + statusEntered.complete(Unit) + releaseStatus.await() + } + return captured + } + } + } + + override suspend fun forget(key: StoreKey) = delegate.forget(key) + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class GateFailureBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + private var gateNext = false + val failureEntered = CompletableDeferred() + val releaseFailure = CompletableDeferred() + + fun gateNextFailure() { + gateNext = true + } + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + delegate.recordFailure(key, atEpochMillis) + if (gateNext) { + gateNext = false + failureEntered.complete(Unit) + releaseFailure.await() + } + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + delegate.forget(key) + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } + + private class GateForgetBookkeeper : Bookkeeper { + private val delegate = InMemoryBookkeeper() + private var gateNext = false + val forgetEntered = CompletableDeferred() + val releaseForget = CompletableDeferred() + + fun gateNextForget() { + gateNext = true + } + + override suspend fun recordSuccess( + key: StoreKey, + meta: StoreMeta, + ) { + delegate.recordSuccess(key, meta) + } + + override suspend fun recordFailure( + key: StoreKey, + atEpochMillis: Long, + ) { + delegate.recordFailure(key, atEpochMillis) + } + + override suspend fun status(key: StoreKey): KeyStatus? = delegate.status(key) + + override suspend fun forget(key: StoreKey) { + delegate.forget(key) + if (gateNext) { + gateNext = false + forgetEntered.complete(Unit) + releaseForget.await() + } + } + + override suspend fun markStale(key: StoreKey) = delegate.markStale(key) + + override suspend fun advanceStaleWatermark(namespace: StoreNamespace) = + delegate.advanceStaleWatermark(namespace) + + override suspend fun advanceGlobalStaleWatermark() = + delegate.advanceGlobalStaleWatermark() + + override suspend fun forgetNamespace(namespace: StoreNamespace) = + delegate.forgetNamespace(namespace) + + override suspend fun forgetAll() = delegate.forgetAll() + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistryTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistryTest.kt new file mode 100644 index 000000000..a2761f7b5 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/KeyRegistryTest.kt @@ -0,0 +1,393 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.FakeWallClock +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.TestKey +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.store +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class KeyRegistryTest { + @Test + fun withEngine_createsOnce_reusesResident() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 1, factory) + val observed = mutableListOf>() + + registry.withEngine(TestKey("a")) { engine -> observed += engine } + registry.withEngine(TestKey("a")) { engine -> observed += engine } + + assertEquals(1, factory.created.size) + assertSame(observed[0], observed[1]) + assertEquals(1L, registry.createdCountForTest()) + } + + @Test + fun release_atZeroRefs_parksQuiescentEngineInIdle() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 1, factory) + + registry.withEngine(TestKey("a")) { + assertEquals(1, registry.residentCountForTest()) + assertEquals(0, registry.idleCountForTest()) + } + + assertEquals(1, registry.residentCountForTest()) + assertEquals(1, registry.idleCountForTest()) + assertFalse(factory.only("a").job.isCancelled) + } + + @Test + fun idleOverflow_evictsEldestOnly() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 2, factory) + + registry.withEngine(TestKey("a")) {} + registry.withEngine(TestKey("b")) {} + registry.withEngine(TestKey("c")) {} + + assertTrue(factory.only("a").job.isCancelled) + assertFalse(factory.only("b").job.isCancelled) + assertFalse(factory.only("c").job.isCancelled) + assertEquals(2, registry.residentCountForTest()) + assertEquals(2, registry.idleCountForTest()) + assertEquals(1L, registry.destroyedCountForTest()) + } + + @Test + fun reacquire_promotesFromIdle_andRefreshesLruPosition() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 2, factory) + var firstA: KeyEngine? = null + + registry.withEngine(TestKey("a")) { engine -> firstA = engine } + registry.withEngine(TestKey("b")) {} + registry.withEngine(TestKey("a")) { engine -> assertSame(firstA, engine) } + registry.withEngine(TestKey("c")) {} + + assertFalse(factory.only("a").job.isCancelled) + assertTrue(factory.only("b").job.isCancelled) + assertFalse(factory.only("c").job.isCancelled) + assertEquals(1, factory.forId("a").size) + } + + @Test + fun maxIdleZero_destroysAtQuiescence() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 0, factory) + + registry.withEngine(TestKey("a")) {} + + assertTrue(factory.only("a").job.isCancelled) + assertEquals(0, registry.residentCountForTest()) + assertEquals(0, registry.idleCountForTest()) + assertEquals(1L, registry.createdCountForTest()) + assertEquals(1L, registry.destroyedCountForTest()) + } + + @Test + fun fetchResidencyHook_pinsEngineAcrossCallerRelease() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 0, factory) + val callerEntered = CompletableDeferred() + val releaseCaller = CompletableDeferred() + val caller = + async(start = CoroutineStart.UNDISPATCHED) { + registry.withEngine(TestKey("a")) { + callerEntered.complete(Unit) + releaseCaller.await() + } + } + callerEntered.await() + val created = factory.only("a") + + created.hooks.retainFetchRef() + releaseCaller.complete(Unit) + caller.await() + + assertEquals(1, registry.residentCountForTest()) + assertEquals(0L, registry.destroyedCountForTest()) + assertFalse(created.job.isCancelled) + + created.hooks.releaseFetchRef() + assertEquals(0, registry.residentCountForTest()) + assertEquals(1L, registry.destroyedCountForTest()) + assertTrue(created.job.isCancelled) + } + + @Test + fun closedRegistry_withEngine_throwsStoreClosed() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 1, factory) + registry.clearOnClose() + + val failure = + assertFailsWith { + registry.withEngine(TestKey("a")) {} + } + + assertEquals(STORE_CLOSED_MESSAGE, failure.message) + assertEquals(0, factory.created.size) + } + + @Test + fun sweep_retainsEngines_andReleasesAfterAction() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 0, factory) + val releaseHolders = CompletableDeferred() + val heldA = CompletableDeferred>() + val heldB = CompletableDeferred>() + val holderA = + async(start = CoroutineStart.UNDISPATCHED) { + registry.withEngine(TestKey("a")) { engine -> + heldA.complete(engine) + releaseHolders.await() + } + } + val holderB = + async(start = CoroutineStart.UNDISPATCHED) { + registry.withEngine(TestKey("b")) { engine -> + heldB.complete(engine) + releaseHolders.await() + } + } + val expected = setOf(heldA.await(), heldB.await()) + val actedOn = mutableSetOf>() + val actionEntered = CompletableDeferred() + val releaseAction = CompletableDeferred() + + val sweep = + async(start = CoroutineStart.UNDISPATCHED) { + registry.snapshotAndForEachResident(namespace = null) { engine -> + actedOn += engine + actionEntered.complete(Unit) + releaseAction.await() + } + } + actionEntered.await() + releaseHolders.complete(Unit) + holderA.await() + holderB.await() + + assertEquals(2, registry.residentCountForTest()) + assertEquals(0, registry.idleCountForTest()) + assertEquals(0L, registry.destroyedCountForTest()) + assertFalse(factory.only("a").job.isCancelled) + assertFalse(factory.only("b").job.isCancelled) + + releaseAction.complete(Unit) + val snapshot = sweep.await() + assertEquals(expected, actedOn) + assertEquals(expected, snapshot.toSet()) + assertEquals(0, registry.residentCountForTest()) + assertEquals(0, registry.idleCountForTest()) + assertEquals(2L, registry.destroyedCountForTest()) + assertTrue(factory.only("a").job.isCancelled) + assertTrue(factory.only("b").job.isCancelled) + } + + @Test + fun sweep_releasesAllRetainedEngines_whenActionThrows() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 0, factory) + val releaseHolders = CompletableDeferred() + val heldA = CompletableDeferred() + val heldB = CompletableDeferred() + val holderA = + async(start = CoroutineStart.UNDISPATCHED) { + registry.withEngine(TestKey("a")) { + heldA.complete(Unit) + releaseHolders.await() + } + } + val holderB = + async(start = CoroutineStart.UNDISPATCHED) { + registry.withEngine(TestKey("b")) { + heldB.complete(Unit) + releaseHolders.await() + } + } + heldA.await() + heldB.await() + + val failure = + assertFailsWith { + registry.snapshotAndForEachResident(namespace = null) { + throw IllegalStateException("sweep failed") + } + } + assertEquals("sweep failed", failure.message) + assertEquals(2, registry.residentCountForTest()) + assertEquals(0L, registry.destroyedCountForTest()) + + releaseHolders.complete(Unit) + holderA.await() + holderB.await() + assertEquals(0, registry.residentCountForTest()) + assertEquals(2L, registry.destroyedCountForTest()) + assertTrue(factory.only("a").job.isCancelled) + assertTrue(factory.only("b").job.isCancelled) + } + + @Test + fun fetchJob_pinsResidencyAfterLastWaiterCancels_untilCommitSettles() = runTest { + val key = TestKey("fetch-residency") + val fetchStarted = CompletableDeferred() + val releaseFetch = CompletableDeferred() + var fetchCalls = 0 + val real = + store { + maxIdleKeys(0) + fetcher { + fetchCalls += 1 + fetchStarted.complete(Unit) + releaseFetch.await() + "committed" + } + } as RealStore + val waiterEngine = CompletableDeferred>() + val firstWaiter = + async(start = CoroutineStart.UNDISPATCHED) { + real.withEngine(key) { engine -> + waiterEngine.complete(engine) + engine.get(Freshness.MustBeFresh) + } + } + try { + fetchStarted.await() + val engine = waiterEngine.await() + assertTrue(engine.state.value.fetch is FetchSlot.InFlight) + firstWaiter.cancelAndJoin() + assertEquals(1, fetchCalls) + assertEquals(1, real.residentEngineCountForTest()) + assertEquals(0, real.idleEngineCountForTest()) + assertEquals(1L, real.createdEngineCountForTest()) + assertEquals(0L, real.destroyedEngineCountForTest()) + + val fetchSettled = + async(start = CoroutineStart.UNDISPATCHED) { + engine.state.first { state -> state.fetch is FetchSlot.Idle } + } + releaseFetch.complete(Unit) + fetchSettled.await() + awaitRegistryCounts(real, resident = 0, destroyed = 1L) + + assertEquals("committed", real.get(key, Freshness.CachedOrFetch)) + assertEquals(1, fetchCalls) + awaitRegistryCounts(real, resident = 0, destroyed = 2L) + assertEquals( + real.residentEngineCountForTest().toLong(), + real.createdEngineCountForTest() - real.destroyedEngineCountForTest(), + ) + } finally { + releaseFetch.complete(Unit) + firstWaiter.cancelAndJoin() + real.close() + real.awaitTerminationForTest() + } + } + + @Test + fun counters_createdMinusDestroyed_equalsResident() = runTest { + val factory = TrackingEngineFactory(backgroundScope) + val registry = registry(maxIdle = 2, factory) + + repeat(5) { index -> registry.withEngine(TestKey("key-$index")) {} } + + assertEquals(5L, registry.createdCountForTest()) + assertEquals(3L, registry.destroyedCountForTest()) + assertEquals( + registry.residentCountForTest().toLong(), + registry.createdCountForTest() - registry.destroyedCountForTest(), + ) + } + + private fun registry( + maxIdle: Int, + factory: TrackingEngineFactory, + ): KeyRegistry = KeyRegistry(maxIdle, factory::create) + + private suspend fun awaitRegistryCounts( + store: RealStore, + resident: Int, + destroyed: Long, + ) { + // Preserve the real-time Default-dispatch hop and let the suite-level runTest bound own + // cancellation. + withContext(Dispatchers.Default) { + while ( + store.residentEngineCountForTest() != resident || + store.destroyedEngineCountForTest() != destroyed + ) { + yield() + } + } + } + + private class TrackingEngineFactory( + private val parentScope: CoroutineScope, + ) { + val created = mutableListOf() + + fun create( + key: TestKey, + id: KeyId, + hooks: EngineResidencyHooks, + ): KeyEngine { + val job = SupervisorJob(parentScope.coroutineContext[Job]) + val engine = + KeyEngine( + key = key, + keyId = id, + fetcher = ResultFetcher { FetcherResult.Success("unused") }, + sot = InMemorySourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = CoroutineScope(parentScope.coroutineContext + job), + residencyHooks = hooks, + ) + created += CreatedEngine(id.canonicalId, engine, job, hooks) + return engine + } + + fun forId(canonicalId: String): List = + created.filter { it.canonicalId == canonicalId } + + fun only(canonicalId: String): CreatedEngine = forId(canonicalId).single() + } + + private data class CreatedEngine( + val canonicalId: String, + val engine: KeyEngine, + val job: Job, + val hooks: EngineResidencyHooks, + ) +} + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinatorTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinatorTest.kt new file mode 100644 index 000000000..4ce892009 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/MaintenanceCoordinatorTest.kt @@ -0,0 +1,321 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.fail + +class MaintenanceCoordinatorTest { + @Test + fun commitsInUnrelatedNamespacesOverlap() = runTest { + val coordinator = MaintenanceCoordinator() + val alphaStarted = CompletableDeferred() + val releaseAlpha = CompletableDeferred() + val betaStarted = CompletableDeferred() + + val alpha = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + alphaStarted.complete(Unit) + releaseAlpha.await() + "alpha" + } + } + alphaStarted.await() + + val beta = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("beta") { + betaStarted.complete(Unit) + "beta" + } + } + + betaStarted.await() + assertEquals("beta", beta.await()) + assertFalse(alpha.isCompleted) + + releaseAlpha.complete(Unit) + assertEquals("alpha", alpha.await()) + } + + @Test + fun namespaceMaintenanceDrainsAndBlocksOnlyItsNamespace() = runTest { + val coordinator = MaintenanceCoordinator() + val activeStarted = CompletableDeferred() + val releaseActive = CompletableDeferred() + val maintenanceStarted = CompletableDeferred() + val releaseMaintenance = CompletableDeferred() + val blockedCommitStarted = CompletableDeferred() + + val active = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + activeStarted.complete(Unit) + releaseActive.await() + } + } + activeStarted.await() + + val maintenance = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withNamespaceMaintenance("alpha") { + maintenanceStarted.complete(Unit) + releaseMaintenance.await() + "maintained" + } + } + val blockedCommit = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + blockedCommitStarted.complete(Unit) + "alpha-after" + } + } + val otherCommit = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("beta") { "beta" } + } + + assertFalse(maintenanceStarted.isCompleted) + assertFalse(blockedCommitStarted.isCompleted) + assertEquals("beta", otherCommit.await()) + + releaseActive.complete(Unit) + active.await() + maintenanceStarted.await() + assertFalse(blockedCommitStarted.isCompleted) + + releaseMaintenance.complete(Unit) + assertEquals("maintained", maintenance.await()) + assertEquals("alpha-after", blockedCommit.await()) + } + + @Test + fun globalMaintenanceDrainsAndBlocksEveryNamespace() = runTest { + val coordinator = MaintenanceCoordinator() + val alphaStarted = CompletableDeferred() + val betaStarted = CompletableDeferred() + val releaseAlpha = CompletableDeferred() + val releaseBeta = CompletableDeferred() + val maintenanceStarted = CompletableDeferred() + val releaseMaintenance = CompletableDeferred() + val laterAlphaStarted = CompletableDeferred() + val laterBetaStarted = CompletableDeferred() + + val alpha = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + alphaStarted.complete(Unit) + releaseAlpha.await() + } + } + val beta = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("beta") { + betaStarted.complete(Unit) + releaseBeta.await() + } + } + alphaStarted.await() + betaStarted.await() + + val maintenance = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withGlobalMaintenance { + maintenanceStarted.complete(Unit) + releaseMaintenance.await() + } + } + val laterAlpha = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + laterAlphaStarted.complete(Unit) + } + } + val laterBeta = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("beta") { + laterBetaStarted.complete(Unit) + } + } + + assertFalse(laterAlphaStarted.isCompleted) + assertFalse(laterBetaStarted.isCompleted) + releaseAlpha.complete(Unit) + alpha.await() + assertFalse(maintenanceStarted.isCompleted) + + releaseBeta.complete(Unit) + beta.await() + maintenanceStarted.await() + assertFalse(laterAlphaStarted.isCompleted) + assertFalse(laterBetaStarted.isCompleted) + + releaseMaintenance.complete(Unit) + maintenance.await() + laterAlpha.await() + laterBeta.await() + } + + @Test + fun cancelledCommitBodyReleasesItsLease() = runTest { + val coordinator = MaintenanceCoordinator() + val commitStarted = CompletableDeferred() + + val commit = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + commitStarted.complete(Unit) + awaitCancellation() + } + } + commitStarted.await() + commit.cancelAndJoin() + + assertEquals( + "maintained", + coordinator.withNamespaceMaintenance("alpha") { "maintained" }, + ) + } + + @Test + fun cancelledDrainingMaintenanceRemovesBlockedScope() = runTest { + val coordinator = MaintenanceCoordinator() + val activeStarted = CompletableDeferred() + val releaseActive = CompletableDeferred() + val maintenanceBodyStarted = CompletableDeferred() + val laterCommitStarted = CompletableDeferred() + val releaseLaterCommit = CompletableDeferred() + + val active = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + activeStarted.complete(Unit) + releaseActive.await() + } + } + activeStarted.await() + + val maintenance = + backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) { + coordinator.withNamespaceMaintenance("alpha") { + maintenanceBodyStarted.complete(Unit) + } + } + assertFalse(maintenanceBodyStarted.isCompleted) + maintenance.cancelAndJoin() + + val laterCommit = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + coordinator.withCommit("alpha") { + laterCommitStarted.complete(Unit) + releaseLaterCommit.await() + } + } + laterCommitStarted.await() + assertFalse(active.isCompleted) + + releaseLaterCommit.complete(Unit) + laterCommit.await() + releaseActive.complete(Unit) + active.await() + } + + @Test + fun commitCallbackCannotEnterSameNamespaceMaintenance() = runTest { + val coordinator = MaintenanceCoordinator() + + assertReentryFailsFast { + coordinator.withCommit("alpha") { + coordinator.withNamespaceMaintenance("alpha") {} + } + } + } + + @Test + fun commitCallbackCannotEnterGlobalMaintenance() = runTest { + val coordinator = MaintenanceCoordinator() + + assertReentryFailsFast { + coordinator.withCommit("alpha") { + coordinator.withGlobalMaintenance {} + } + } + } + + @Test + fun maintenanceCallbackCannotEnterMaintenance() = runTest { + val coordinator = MaintenanceCoordinator() + + assertReentryFailsFast { + coordinator.withNamespaceMaintenance("alpha") { + coordinator.withGlobalMaintenance {} + } + } + } + + @Test + fun maintenanceCallbackCannotEnterSameScopeCommit() = runTest { + val coordinator = MaintenanceCoordinator() + + assertReentryFailsFast { + coordinator.withNamespaceMaintenance("alpha") { + coordinator.withCommit("alpha") {} + } + } + } + + @Test + fun nestedCoordinatorChainCannotReenterEarlierCoordinator() = runTest { + val first = MaintenanceCoordinator() + val second = MaintenanceCoordinator() + + assertReentryFailsFast { + first.withCommit("alpha") { + second.withCommit("beta") { + first.withCommit("gamma") {} + } + } + } + } + + @Test + fun callbackMayEnterIndependentCoordinator() = runTest { + val first = MaintenanceCoordinator() + val second = MaintenanceCoordinator() + + assertEquals( + "independent", + first.withCommit("alpha") { + second.withNamespaceMaintenance("alpha") { "independent" } + }, + ) + } + + private suspend fun TestScope.assertReentryFailsFast(block: suspend () -> Unit) { + val attempt = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + runCatching { block() } + } + if (!attempt.isCompleted) { + attempt.cancelAndJoin() + fail("Re-entry suspended instead of failing fast") + } + + val failure = assertIs(attempt.await().exceptionOrNull()) + assertEquals( + "MaintenanceCoordinator callbacks cannot re-enter the same coordinator.", + failure.message, + ) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/OverlayProjectionProtocolTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/OverlayProjectionProtocolTest.kt new file mode 100644 index 000000000..8ae0fefc0 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/OverlayProjectionProtocolTest.kt @@ -0,0 +1,1951 @@ +package org.mobilenativefoundation.store6.core.internal + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.test +import app.cash.turbine.testIn +import app.cash.turbine.turbineScope +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.TestKey +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.Overlay +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth +import org.mobilenativefoundation.store6.core.seam.StoreTelemetry +import org.mobilenativefoundation.store6.core.seam.runtime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** Deterministic proofs for the private base/revision/generation projection protocol. */ +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class OverlayProjectionProtocolTest { + private val key = TestKey("overlay-protocol") + + @Test + fun projectionVocabulary_keepsExactThreeVariants() { + val envelope = + ValueEnvelope( + value = "v", + origin = Origin.SOT, + meta = null, + staleEpochAtCommit = 0L, + ) + + assertSame(envelope, assertIs>(Projection.Value(envelope)).envelope) + assertEquals("optimistic", assertIs>(Projection.Overlaid("optimistic")).value) + assertSame(Projection.Absent, Projection.Absent) + } + + @Test + fun staleV1Apply_isDiscardedAfterResidenceAdvancesToV2() = runTest { + val overlay = CountingOverlay({ base -> base?.plus("+projected") }) + val staleGate = SuspendGate() + var blockV1 = true + val harness = + stringEngine( + overlay = overlay, + fetcher = { FetcherResult.Success("v1") }, + afterProjectionApplyTestGate = { base -> + if (base == "v1" && blockV1) { + blockV1 = false + staleGate.pause() + } + }, + ) + + try { + harness.engine.stream(Freshness.CachedOrFetch).test { + assertIs(awaitItem()) + staleGate.awaitEntered() + + val write = async(Dispatchers.Default) { harness.engine.applyWrite("v2") } + awaitFromDefault { write.await() } + staleGate.release() + + val data = awaitDataValue("v2+projected") + assertEquals(Origin.OVERLAY, data.origin) + assertFalse(seenDataValues.contains("v1+projected")) + cancelAndIgnoreRemainingEvents() + } + } finally { + staleGate.release() + harness.close() + } + } + + @Test + fun sameEnvelopeAtNewRevision_cannotConsumeOldReadiness() = runTest { + val value = RefValue("same", "base") + val source = SharedFlowSourceOfTruth() + val projectionNumber = MutableStateFlow(0) + val completedProjections = Channel(Channel.UNLIMITED) + val overlay = CountingOverlay({ base -> + base?.let { + val projected = + RefValue( + id = it.id, + tag = "projection-${projectionNumber.updateAndGet { number -> number + 1 }}", + ) + check(completedProjections.trySend(projected).isSuccess) + projected + } + }) + val staleGate = SuspendGate() + val readerGate = SuspendGate() + val blockProjection = MutableStateFlow(false) + val blockReader = MutableStateFlow(false) + val harness = + refEngine( + overlay = overlay, + sot = source, + fetcher = { FetcherResult.Success(value) }, + afterProjectionApplyTestGate = { base -> + if (base === value && blockProjection.compareAndSet(true, false)) { + staleGate.pause() + } + }, + beforeReaderDeliveryTestGate = { + if (blockReader.compareAndSet(true, false)) { + readerGate.pause() + } + }, + ) + + try { + harness.engine.stream(Freshness.CachedOrFetch).test { + awaitRefData() + overlay.clearCalls() + while (completedProjections.tryReceive().isSuccess) Unit + blockProjection.value = true + overlay.signals.emit(key) + staleGate.awaitEntered() + assertSame(value, overlay.awaitCall()) + val rejectedTag = completedProjections.receive().tag + + blockReader.value = true + source.write(key, value) + readerGate.awaitEntered() // mapping and its revision bump already happened + staleGate.release() + + val latestCall = overlay.awaitCall() + assertSame(value, latestCall) + val acceptedTag = completedProjections.receive().tag + readerGate.release() + val latest = awaitDataTag(acceptedTag) + assertEquals(Origin.OVERLAY, latest.origin) + assertFalse(seenDataTags.contains(rejectedTag)) + cancelAndIgnoreRemainingEvents() + } + } finally { + staleGate.release() + readerGate.release() + harness.close() + } + } + + @Test + fun queuedReady_cannotRenderAfterResidenceAdvances() = runTest { + val staleDelivery = SuspendGate() + val staleDelivered = SuspendGate() + val initialReader = SuspendGate() + val newerReader = SuspendGate() + val source = ContractSourceOfTruth() + var blockStaleDelivery = false + var watchStaleDelivered = false + var blockInitialReader = true + var blockNewerReader = false + var suffix = "initial" + val overlay = CountingOverlay({ base -> base?.plus("+$suffix") }) + val harness = + stringEngine( + overlay = overlay, + sot = source, + fetcher = { awaitCancellation() }, + beforeReaderDeliveryLockTestGate = { record -> + if ( + blockNewerReader && + record is ReaderRecord.Row && + record.envelope.value == "v2" + ) { + blockNewerReader = false + newerReader.pause() + } + }, + beforeReaderDeliveryTestGate = { + if (blockInitialReader) { + blockInitialReader = false + initialReader.pause() + } + }, + beforeProjectionDeliveryLockTestGate = { + if (blockStaleDelivery) { + blockStaleDelivery = false + staleDelivery.pause() + } + }, + afterProjectionDeliveryTestGate = { + if (watchStaleDelivered) { + watchStaleDelivered = false + staleDelivered.pause() + } + }, + ) + + try { + harness.engine.applyWrite("v1") + harness.engine.stream(Freshness.LocalOnly).test { + awaitDataValue("v1+initial") + source.readerStarted.await() + initialReader.awaitEntered() + + suffix = "barrier" + overlay.clearCalls() + overlay.signals.emit(key) + assertEquals("v1", overlay.awaitCall()) + initialReader.release() + initialReader.awaitExited() + awaitDataValue("v1+barrier") + + suffix = "stale" + blockStaleDelivery = true + watchStaleDelivered = true + overlay.clearCalls() + overlay.signals.emit(key) + assertEquals("v1", overlay.awaitCall()) + staleDelivery.awaitEntered() + + suffix = "latest" + blockNewerReader = true + harness.engine.applyWrite("v2") + newerReader.awaitEntered() + while (overlay.awaitCall() != "v2") Unit + + staleDelivery.release() + staleDelivered.awaitEntered() + staleDelivered.release() + newerReader.release() + awaitDataValue("v2+latest") + assertFalse(seenDataValues.contains("v1+stale")) + cancelAndIgnoreRemainingEvents() + } + } finally { + staleDelivery.release() + staleDelivered.release() + initialReader.release() + newerReader.release() + harness.close() + } + } + + @Test + fun queuedOlderReady_cannotReplayAfterFailureFlushesLatestGeneration() = runTest { + val fetch = SuspendGate() + val oldDelivery = SuspendGate() + val boom = IllegalStateException("offline") + var calls = 0 + var suffix = "initial" + var blockOldDelivery = false + val overlay = CountingOverlay({ base -> base?.plus("+$suffix") }) + val harness = + stringEngine( + overlay = overlay, + fetcher = { + when (++calls) { + 1 -> FetcherResult.Success("v") + 2 -> { + fetch.pause() + FetcherResult.Error(boom) + } + else -> error("unexpected fetch $calls") + } + }, + beforeProjectionDeliveryLockTestGate = { + if (blockOldDelivery) { + blockOldDelivery = false + oldDelivery.pause() + } + }, + ) + + try { + harness.engine.stream(Freshness.StaleIfError).test { + awaitDataValue("v+initial") + harness.engine.invalidate() + fetch.awaitEntered() + + suffix = "old" + blockOldDelivery = true + overlay.clearCalls() + overlay.signals.emit(key) + assertEquals("v", overlay.awaitCall()) + oldDelivery.awaitEntered() + + suffix = "latest" + overlay.signals.emit(key) + assertEquals("v", overlay.awaitCall()) + fetch.release() + + assertEquals("v+latest", awaitDataValue().value) + assertIs(assertIs(awaitItem()).error) + + oldDelivery.release() + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + fetch.release() + oldDelivery.release() + harness.close() + } + } + + @Test + fun failureFlush_replansWhenAuthorizedProjectionObsoletes() = runTest { + val fetch = SuspendGate() + val oldProjection = SuspendGate() + val readerMapping = SuspendGate() + val refreshProjectionDelivery = SuspendGate() + val outcomeDelivery = SuspendGate() + val firstReadiness = SuspendGate() + val secondReadiness = SuspendGate() + val boom = IllegalStateException("offline") + var suffix = "initial" + var blockOldProjection = false + var deliverRefreshProjection = false + var watchReadiness = false + var readinessCalls = 0 + val overlay = CountingOverlay({ base -> base?.plus("+$suffix") }) + val source = ContractSourceOfTruth() + val harness = + stringEngine( + overlay = overlay, + sot = source, + fetcher = { + fetch.pause() + FetcherResult.Error(boom) + }, + beforeReaderRecordMappingTestGate = { readerMapping.pause() }, + afterProjectionApplyTestGate = { base -> + if (base == "v1" && blockOldProjection) { + blockOldProjection = false + oldProjection.pause() + } + }, + afterProjectionDeliveryTestGate = { + if (deliverRefreshProjection) { + deliverRefreshProjection = false + refreshProjectionDelivery.pause() + } + }, + beforeTicketOutcomeDeliveryTestGate = { outcomeDelivery.pause() }, + beforeProjectionReadinessWaitTestGate = { + if (watchReadiness) { + when (++readinessCalls) { + 1 -> firstReadiness.pause() + 2 -> secondReadiness.pause() + } + } + }, + ) + + try { + harness.engine.applyWrite("v1") + overlay.awaitCallValue("v1") + harness.engine.stream(Freshness.StaleIfError).test { + awaitDataValue("v1+initial") + source.readerStarted.await() + readerMapping.awaitEntered() + harness.engine.invalidate() + fetch.awaitEntered() + + suffix = "refreshing" + deliverRefreshProjection = true + overlay.clearCalls() + overlay.signals.emit(key) + assertEquals("v1", overlay.awaitCall()) + refreshProjectionDelivery.awaitEntered() + refreshProjectionDelivery.release() + refreshProjectionDelivery.awaitExited() + awaitDataValue("v1+refreshing") + + suffix = "latest" + blockOldProjection = true + overlay.clearCalls() + overlay.signals.emit(key) + assertEquals("v1", overlay.awaitCall()) + oldProjection.awaitEntered() + + fetch.release() + outcomeDelivery.awaitEntered() + watchReadiness = true + outcomeDelivery.release() + outcomeDelivery.awaitExited() + firstReadiness.awaitEntered() + + val write = async(Dispatchers.Default) { harness.engine.applyWrite("v2") } + awaitFromDefault { write.await() } + val nextItem = async { awaitItem() } + val secondWait = async(Dispatchers.Default) { secondReadiness.awaitEntered() } + firstReadiness.release() + + val winner = + select { + secondWait.onAwait { "replanned" } + nextItem.onAwait { "public-item" } + } + assertEquals("replanned", winner) + + oldProjection.release() + assertEquals("v2", overlay.awaitCall()) + secondReadiness.release() + val projected = assertIs>(nextItem.await()) + assertEquals("v2+latest", projected.value) + assertEquals(Origin.OVERLAY, projected.origin) + assertIs(assertIs(awaitItem()).error) + cancelAndIgnoreRemainingEvents() + } + } finally { + fetch.release() + oldProjection.release() + readerMapping.release() + refreshProjectionDelivery.release() + outcomeDelivery.release() + firstReadiness.release() + secondReadiness.release() + harness.close() + } + } + + @Test + fun pendingOldBase_obsoletesWaiterAndQueuedWriteCompletes() = runTest { + val overlay = CountingOverlay({ base -> base?.plus("+op") }) + val pendingGate = SuspendGate() + var blockNull = true + val harness = + stringEngine( + overlay = overlay, + fetcher = { awaitCancellation() }, + beforeProjectionApplyTestGate = { base -> + if (base == null && blockNull) { + blockNull = false + pendingGate.pause() + } + }, + ) + + try { + harness.engine.stream(Freshness.LocalOnly).test { + pendingGate.awaitEntered() + val write = async(Dispatchers.Default) { harness.engine.applyWrite("confirmed") } + awaitFromDefault { write.await() } + pendingGate.release() + + val data = awaitDataValue("confirmed+op") + assertEquals(Origin.OVERLAY, data.origin) + + overlay.transform = { it } + overlay.signals.emit(key) + val retired = awaitDataValue("confirmed") + assertTrue(retired.origin == Origin.SOT || retired.origin == Origin.MEMORY) + cancelAndIgnoreRemainingEvents() + } + } finally { + pendingGate.release() + harness.close() + } + } + + @Test + fun foreignDirectOwner_sameReferenceBaselineRemainsLive() = runTest { + val resident = RefValue("same", "base") + val secondFetch = SuspendGate() + val outcomeGate = SuspendGate() + var calls = 0 + val overlay = CountingOverlay({ it }) + val harness = + refEngine( + overlay = overlay, + fetcher = { + when (++calls) { + 1 -> FetcherResult.Success(resident, etag = "e1") + 2 -> { + secondFetch.pause() + FetcherResult.NotModified("e2") + } + else -> error("unexpected fetch $calls") + } + }, + beforeTicketOutcomeDeliveryTestGate = { + if (calls == 2) outcomeGate.pause() + }, + ) + + try { + harness.engine.stream(Freshness.CachedOrFetch).test { + awaitDataTag("base") + harness.engine.invalidate() + secondFetch.awaitEntered() + secondFetch.release() + outcomeGate.awaitEntered() // refreshed direct-owner envelope is now residence + + overlay.transform = { base -> base?.let { RefValue(it.id, "same-ref-overlay") } } + overlay.signals.emit(key) + val projected = awaitDataTag("same-ref-overlay") + assertEquals(Origin.OVERLAY, projected.origin) + outcomeGate.release() + cancelAndIgnoreRemainingEvents() + } + } finally { + secondFetch.release() + outcomeGate.release() + harness.close() + } + } + + @Test + fun obsoleteForeignWait_restoresPreviousVisibleAuthorization() = runTest { + val visible = RefValue("visible", "base") + val interloper = RefValue("interloper", "base") + val settleMarker = RefValue("visible", "settle-marker") + val projected = RefValue("visible", "foreign-overlay") + val source = ContractSourceOfTruth() + val interloperApply = SuspendGate() + val interloperReadiness = SuspendGate() + val initialVisibleReader = SuspendGate() + val visibleEchoMapping = SuspendGate() + val outcomeDelivery = SuspendGate() + val projectionDelivered = SuspendGate() + val telemetry = RecordingTelemetry() + var blockInterloperApply = false + var blockInterloperReadiness = false + var blockInitialVisibleReader = true + var blockVisibleEchoMapping = false + var watchProjectionDelivery = false + val overlay = CountingOverlay({ it }) + val harness = + refEngine( + overlay = overlay, + sot = source, + fetcher = { FetcherResult.NotModified("fresh") }, + beforeReaderRecordMappingTestGate = { + if (blockVisibleEchoMapping) { + blockVisibleEchoMapping = false + visibleEchoMapping.pause() + } + }, + afterProjectionApplyTestGate = { base -> + if (base === interloper && blockInterloperApply) { + blockInterloperApply = false + interloperApply.pause() + } + }, + beforeProjectionReadinessWaitTestGate = { + if (blockInterloperReadiness) { + blockInterloperReadiness = false + interloperReadiness.pause() + } + }, + beforeReaderDeliveryTestGate = { + if (blockInitialVisibleReader) { + blockInitialVisibleReader = false + initialVisibleReader.pause() + } + }, + beforeTicketOutcomeDeliveryTestGate = { outcomeDelivery.pause() }, + afterProjectionDeliveryTestGate = { + if ( + watchProjectionDelivery && + telemetry.serves.lastOrNull() == Origin.OVERLAY + ) { + watchProjectionDelivery = false + projectionDelivered.pause() + } + }, + telemetry = telemetry, + ) + + try { + harness.engine.applyWrite(visible) + overlay.awaitCallValue(visible) + harness.engine.stream(Freshness.LocalOnly).test { + assertSame(visible, awaitRefData().value) + source.readerStarted.await() + initialVisibleReader.awaitEntered() + overlay.transform = { base -> if (base === visible) settleMarker else base } + overlay.signals.emit(key) + initialVisibleReader.release() + initialVisibleReader.awaitExited() + assertSame(settleMarker, awaitDataTag("settle-marker").value) + telemetry.awaitServe(Origin.OVERLAY) + telemetry.clear() + + overlay.transform = { it } + overlay.signals.emit(key) + assertSame(visible, awaitDataTag("base").value) + telemetry.awaitServe() + telemetry.clear() + + blockInterloperApply = true + blockInterloperReadiness = true + source.write(key, interloper) + interloperApply.awaitEntered() + interloperReadiness.awaitEntered() + + blockVisibleEchoMapping = true + harness.engine.applyWrite(visible) + val fresh = async(Dispatchers.Default) { harness.engine.get(Freshness.MustBeFresh) } + outcomeDelivery.awaitEntered() + + overlay.transform = { base -> if (base === visible) projected else base } + telemetry.clear() + interloperReadiness.release() + visibleEchoMapping.awaitEntered() + + watchProjectionDelivery = true + interloperApply.release() + overlay.awaitCallValue(visible) + projectionDelivered.awaitEntered() + + assertEquals(listOf(Origin.OVERLAY), telemetry.serves) + projectionDelivered.release() + assertSame(projected, awaitRefData().value) + + outcomeDelivery.release() + assertSame(visible, awaitFromDefault { fresh.await() }) + cancelAndIgnoreRemainingEvents() + } + } finally { + interloperApply.release() + interloperReadiness.release() + initialVisibleReader.release() + visibleEchoMapping.release() + outcomeDelivery.release() + projectionDelivered.release() + harness.close() + } + } + + @Test + fun foreignDirectOwner_equalButDistinctReaderFirstReusesExactAuthorizedBaseline() = + runForeignDirectOwnerEqualDistinctOrdering(ForeignOwnerOrdering.READER_FIRST) + + @Test + fun foreignDirectOwner_equalButDistinctOwnerFirstNeverReusesOlderBaseline() = + runForeignDirectOwnerEqualDistinctOrdering(ForeignOwnerOrdering.OWNER_FIRST) + + private fun runForeignDirectOwnerEqualDistinctOrdering(ordering: ForeignOwnerOrdering) = runTest { + val older = RefValue("equal", "base") + val newer = RefValue("equal", "base") + val mustNotLeak = RefValue("equal", "must-not-leak") + assertEquals(older, newer) + assertTrue(older !== newer) + val fetchGate = SuspendGate() + val outcomeGate = SuspendGate() + val preWriteMapping = SuspendGate() + val orderingMapping = SuspendGate() + val trailingMapping = SuspendGate() + val initialObserverProjection = SuspendGate() + val writerProjection = SuspendGate() + val ownerProjection = SuspendGate() + val targetProjection = SuspendGate() + val projectionAfterGates = Channel(Channel.UNLIMITED) + val telemetry = RecordingTelemetry() + val source = ContractSourceOfTruth() + var calls = 0 + var readerMappingCalls = 0 + val overlay = CountingOverlay({ it }) + val harness = + refEngine( + overlay = overlay, + sot = source, + fetcher = { + when (++calls) { + 1 -> { + fetchGate.pause() + FetcherResult.NotModified("e2") + } + else -> error("unexpected fetch $calls") + } + }, + beforeTicketOutcomeDeliveryTestGate = { + if (calls == 1) outcomeGate.pause() + }, + beforeReaderRecordMappingTestGate = { + when (++readerMappingCalls) { + 1 -> { + preWriteMapping.pause() + orderingMapping.pause() + } + else -> trailingMapping.pause() + } + }, + afterProjectionDeliveryTestGate = { + projectionAfterGates.tryReceive().getOrNull()?.pause() + }, + telemetry = telemetry, + ) + + try { + harness.engine.applyWrite(older) + overlay.awaitCallValue(older) + overlay.clearCalls() + check(projectionAfterGates.trySend(initialObserverProjection).isSuccess) + harness.engine.stream(Freshness.CachedOrFetch).test { + val initial = awaitRefData() + assertSame(older, initial.value) + assertEquals(Origin.SOT, initial.origin) + source.readerStarted.await() + preWriteMapping.awaitEntered() + initialObserverProjection.awaitEntered() + initialObserverProjection.release() + initialObserverProjection.awaitExited() + telemetry.clear() + overlay.clearCalls() + + check(projectionAfterGates.trySend(writerProjection).isSuccess) + harness.engine.applyWrite(newer) + assertSame(newer, overlay.awaitCall()) + writerProjection.awaitEntered() + writerProjection.release() + writerProjection.awaitExited() + overlay.clearCalls() + + preWriteMapping.release() + preWriteMapping.awaitExited() + orderingMapping.awaitEntered() + if (ordering == ForeignOwnerOrdering.READER_FIRST) { + orderingMapping.release() + orderingMapping.awaitExited() + trailingMapping.awaitEntered() + trailingMapping.release() + trailingMapping.awaitExited() + val current = awaitRefData() + assertSame(newer, current.value) + assertEquals(Origin.SOT, current.origin) + assertEquals(Origin.SOT, telemetry.awaitServe(Origin.SOT)) + } + + val foreignOwner = async(Dispatchers.Default) { + harness.engine.get(Freshness.MustBeFresh) + } + fetchGate.awaitEntered() + check(projectionAfterGates.trySend(ownerProjection).isSuccess) + fetchGate.release() + outcomeGate.awaitEntered() + ownerProjection.awaitEntered() + ownerProjection.release() + ownerProjection.awaitExited() + + overlay.transform = { mustNotLeak } + overlay.clearCalls() + check(projectionAfterGates.trySend(targetProjection).isSuccess) + overlay.signals.emit(key) + assertSame(newer, overlay.awaitCall()) + targetProjection.awaitEntered() + targetProjection.release() + targetProjection.awaitExited() + + if (ordering == ForeignOwnerOrdering.READER_FIRST) { + val projected = awaitRefData() + assertSame(mustNotLeak, projected.value) + assertEquals(Origin.OVERLAY, projected.origin) + assertEquals(Origin.OVERLAY, telemetry.awaitServe(Origin.OVERLAY)) + outcomeGate.release() + assertSame(newer, awaitFromDefault { foreignOwner.await() }) + cancelAndIgnoreRemainingEvents() + } else { + expectNoEvents() + assertFalse(telemetry.serves.contains(Origin.OVERLAY)) + outcomeGate.release() + assertSame(newer, awaitFromDefault { foreignOwner.await() }) + cancelAndIgnoreRemainingEvents() + } + } + } finally { + fetchGate.release() + outcomeGate.release() + preWriteMapping.release() + orderingMapping.release() + trailingMapping.release() + initialObserverProjection.release() + writerProjection.release() + ownerProjection.release() + targetProjection.release() + harness.close() + } + } + + @Test + fun policyWithheldNonNullBase_doesNotAuthorizeProjection() = runTest { + var calls = 0 + val refresh = SuspendGate() + val overlay = CountingOverlay({ base -> base?.plus("+optimistic") }) + val store = store { + fetcher { + when (++calls) { + 1 -> "v1" + 2 -> { + refresh.pause() + "v2" + } + else -> error("unexpected fetch $calls") + } + } + overlay(overlay) + } + + try { + store.stream(key).test { + awaitDataValue("v1+optimistic") + cancelAndIgnoreRemainingEvents() + } + store.invalidate(key) + store.stream(key, Freshness.MustBeFresh).test { + assertIs(awaitItem()) + refresh.awaitEntered() + expectNoEvents() + refresh.release() + awaitDataValue("v2+optimistic") + cancelAndIgnoreRemainingEvents() + } + } finally { + refresh.release() + store.close() + } + } + + @Test + fun nullOverlay_preservesEqualButDistinctForeignBaselineReuse() = runTest { + val visible = RefValue("visible", "visible") + val mapped = RefValue("equal", "value") + val ownerValue = RefValue("equal", "value") + assertEquals(mapped, ownerValue) + assertTrue(mapped !== ownerValue) + val source = ContractSourceOfTruth() + val mappedDelivery = SuspendGate() + var blockMapped = false + val harness = + refEngine( + overlay = null, + sot = source, + fetcher = { FetcherResult.NotModified("e1") }, + beforeReaderDeliveryLockTestGate = { record -> + if ( + blockMapped && + record is ReaderRecord.Row && + record.envelope.value === mapped + ) { + blockMapped = false + mappedDelivery.pause() + } + }, + ) + + try { + harness.engine.applyWrite(visible) + harness.engine.stream(Freshness.LocalOnly).test { + assertSame(visible, awaitRefData().value) + source.readerStarted.await() + + blockMapped = true + source.write(key, mapped) + mappedDelivery.awaitEntered() + harness.engine.applyWrite(ownerValue) + assertSame(ownerValue, harness.engine.get(Freshness.MustBeFresh)) + + mappedDelivery.release() + assertSame(mapped, awaitRefData().value) + cancelAndIgnoreRemainingEvents() + } + } finally { + mappedDelivery.release() + harness.close() + } + } + + @Test + fun confirmedAbsenceAuthorizesCreate_readerFailureDoesNot() = runTest { + val create = CountingOverlay({ it ?: "optimistic-create" }) + val absentStore = store { + fetcher { awaitCancellation() } + overlay(create) + } + val readerBoom = IllegalStateException("reader failed") + val liveReaderFailure = SuspendGate() + val projectionDelivery = SuspendGate() + val failingHarness = + stringEngine( + overlay = CountingOverlay({ it ?: "must-not-appear" }), + fetcher = { awaitCancellation() }, + sot = FailingReaderSourceOfTruth(readerBoom, liveReaderFailure), + afterProjectionDeliveryTestGate = { projectionDelivery.pause() }, + ) + + try { + absentStore.stream(key).test { + val created = awaitDataValue("optimistic-create") + assertEquals(Origin.OVERLAY, created.origin) + cancelAndIgnoreRemainingEvents() + } + failingHarness.engine.stream(Freshness.CachedOrFetch).test { + assertIs(awaitItem()) + val failure = assertIs(awaitItem()) + assertIs(failure.error) + + liveReaderFailure.awaitEntered() + projectionDelivery.awaitEntered() + expectNoEvents() // the ready optimistic-create snapshot was observed but rejected + + projectionDelivery.release() + liveReaderFailure.release() + val liveFailure = assertIs(awaitItem()) + assertIs(liveFailure.error) + cancelAndIgnoreRemainingEvents() + } + } finally { + liveReaderFailure.release() + projectionDelivery.release() + absentStore.close() + failingHarness.close() + } + } + + @Test + fun identityProjection_preservesLateCollectorMemoryOrigin() = runTest { + val store = store { + fetcher { "v" } + overlay(CountingOverlay({ it })) + } + + try { + assertEquals("v", store.get(key)) + store.stream(key, Freshness.LocalOnly).test { + val data = awaitDataValue("v") + assertEquals(Origin.MEMORY, data.origin) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun revalidated_identityHasNoExtraData_andTelemetryUsesEffectiveOrigin() = runTest { + val telemetry = RecordingTelemetry() + revalidationScenario( + overlay = CountingOverlay({ it }), + telemetry = telemetry, + ) { turbine -> + val revalidated = turbine.awaitNonDataTerminal() + assertIs(revalidated) + turbine.expectNoEvents() + assertEquals(listOf(Origin.FETCHER), telemetry.serves) + } + } + + @Test + fun revalidated_overlaidOrdersProjectionThenLifecycle_andTelemetryUsesOverlay() = runTest { + val telemetry = RecordingTelemetry() + var suffix = "one" + val overlay = CountingOverlay({ base -> base?.plus("+$suffix") }) + revalidationScenario( + overlay = overlay, + telemetry = telemetry, + beforeRelease = { + suffix = "two" + overlay.signals.emit(key) + }, + ) { turbine -> + val data = assertIs>(turbine.awaitItem()) + assertEquals("v+two", data.value) + assertEquals(Origin.OVERLAY, data.origin) + assertIs(turbine.awaitItem()) + assertEquals(listOf(Origin.OVERLAY, Origin.OVERLAY), telemetry.serves) + } + } + + @Test + fun revalidated_unchangedOverlaidHasNoExtraData_andSingleTelemetryHook() = runTest { + val telemetry = RecordingTelemetry() + revalidationScenario( + overlay = CountingOverlay({ base -> base?.let { "stable-overlay" } }), + telemetry = telemetry, + ) { turbine -> + val terminal = turbine.awaitNonDataTerminal() + val fetchFailure = + (terminal as? StoreResult.Error)?.error as? StoreError.Fetch + assertIs(terminal, fetchFailure?.cause?.message) + turbine.expectNoEvents() + assertEquals(listOf(Origin.OVERLAY), telemetry.serves) + } + } + + @Test + fun revalidated_absentOrdersLoadingThenLifecycle_andSkipsTelemetry() = runTest { + val telemetry = RecordingTelemetry() + var absent = false + val overlay = CountingOverlay({ base -> if (absent) null else base }) + revalidationScenario( + overlay = overlay, + telemetry = telemetry, + beforeRelease = { + absent = true + overlay.signals.emit(key) + }, + ) { turbine -> + assertIs(turbine.awaitItem()) + assertIs(turbine.awaitItem()) + assertTrue(telemetry.serves.isEmpty()) + } + } + + @Test + fun failedFetch_flushesOverlaidProjectionBeforeError_andReportsServedStale() = runTest { + failedProjectionScenario(projectAbsent = false) { events -> + assertEquals(listOf("data:v+latest", "error:true"), events) + } + } + + @Test + fun failedFetch_flushesAbsentProjectionBeforeError_andClearsServedStale() = runTest { + failedProjectionScenario(projectAbsent = true) { events -> + assertEquals(listOf("loading", "error:false"), events) + } + } + + @Test + fun serverDelete_projectsExactAbsenceBeforeTerminalError() = runTest { + var calls = 0 + val deletion = SuspendGate() + val overlay = CountingOverlay({ base -> base ?: "optimistic-after-delete" }) + val store = store { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v") + 2 -> { + deletion.pause() + FetcherResult.Deleted + } + else -> error("unexpected fetch $calls") + } + } + overlay(overlay) + } + + try { + store.stream(key).test { + awaitDataValue("v") + store.invalidate(key) + deletion.awaitEntered() + deletion.release() + + val optimistic = awaitDataValue("optimistic-after-delete") + assertEquals(Origin.OVERLAY, optimistic.origin) + val failure = assertIs(awaitItem()) + assertIs(failure.error) + cancelAndIgnoreRemainingEvents() + } + } finally { + deletion.release() + store.close() + } + } + + @Test + fun mustBeFreshInitialDelete_projectsExactAbsenceBeforeTerminalError() = runTest { + val deletion = SuspendGate() + val overlay = CountingOverlay({ base -> base ?: "optimistic-after-delete" }) + val store = store { + fetcherOfResult { + deletion.pause() + FetcherResult.Deleted + } + overlay(overlay) + } + + try { + store.runtime()!!.writeHandle.apply(key, "confirmed") + store.stream(key, Freshness.MustBeFresh).test { + assertIs(awaitItem()) + deletion.awaitEntered() + deletion.release() + assertEquals("optimistic-after-delete", awaitDataValue().value) + assertIs(assertIs(awaitItem()).error) + cancelAndIgnoreRemainingEvents() + } + } finally { + deletion.release() + store.close() + } + } + + @Test + fun mustBeFreshInitialFailure_flushesLatestProjectionBeforeError() = runTest { + val fetch = SuspendGate() + val boom = IllegalStateException("offline") + var projected = "one" + val overlay = CountingOverlay({ base -> base ?: projected }) + val store = store { + fetcherOfResult { + fetch.pause() + FetcherResult.Error(boom) + } + overlay(overlay) + } + + try { + store.stream(key, Freshness.MustBeFresh).test { + awaitDataValue("one") + fetch.awaitEntered() + overlay.clearCalls() + projected = "two" + overlay.signals.emit(key) + assertEquals(null, overlay.awaitCall()) + fetch.release() + + assertEquals("two", awaitDataValue().value) + assertIs(assertIs(awaitItem()).error) + cancelAndIgnoreRemainingEvents() + } + } finally { + fetch.release() + store.close() + } + } + + @Test + fun mustBeFreshWaitingFetch_observesOverlayTerminalForCurrentAndFutureStreams() = runTest { + val boom = IllegalStateException("projection failed while fetch waits") + val failChanges = CompletableDeferred() + val overlay = + CountingOverlay( + changes = flow { + failChanges.await() + throw boom + }, + transform = { base -> base }, + ) + val store = store { + fetcher { awaitCancellation() } + overlay(overlay) + } + + try { + store.runtime()!!.writeHandle.apply(key, "resident") + store.stream(key, Freshness.LocalOnly).test { + awaitDataValue("resident") + cancelAndIgnoreRemainingEvents() + } + + store.stream(key, Freshness.MustBeFresh).test { + assertIs(awaitItem()) + failChanges.complete(Unit) + + val terminal = assertIs(awaitError()) + assertEquals(boom::class, terminal.cause!!::class) + assertEquals(boom.message, terminal.cause?.message) + } + + val futureFailure = + runCatching { + store.stream(key, Freshness.MustBeFresh).collect { } + }.exceptionOrNull() + val futureTerminal = assertIs(futureFailure) + assertEquals(boom::class, futureTerminal.cause!!::class) + assertEquals(boom.message, futureTerminal.cause?.message) + } finally { + failChanges.complete(Unit) + store.close() + } + } + + @Test + fun applyFailureWithLiveChangesCollector_retainsOriginalCauseForCurrentAndFutureStreams() = + runTest { + val boom = IllegalStateException("apply failed with live changes collector") + val failProjection = MutableStateFlow(false) + val signals = MutableSharedFlow(replay = 1) + val overlay = + object : Overlay { + override val changes: Flow = signals + + override fun apply( + key: TestKey, + base: String?, + ): String? { + if (failProjection.value) throw boom + return base + } + } + val store = store { + fetcher { awaitCancellation() } + overlay(overlay) + } + + try { + store.runtime()!!.writeHandle.apply(key, "resident") + store.stream(key, Freshness.LocalOnly).test { + awaitDataValue("resident") + cancelAndIgnoreRemainingEvents() + } + withContext(Dispatchers.Default) { + signals.subscriptionCount.first { count -> count > 0 } + } + + store.stream(key, Freshness.MustBeFresh).test { + assertIs(awaitItem()) + failProjection.value = true + signals.emit(key) + + val terminal = assertIs(awaitError()) + assertEquals(boom::class, terminal.cause!!::class) + assertEquals(boom.message, terminal.cause?.message) + } + + assertTerminalCause(store, boom) + } finally { + store.close() + } + } + + @Test + fun closeCancelsPendingReadiness_withoutTerminalizingCooperativeClose() = runTest { + val pending = SuspendGate() + val readiness = SuspendGate() + val harness = + stringEngine( + overlay = CountingOverlay({ it }), + fetcher = { awaitCancellation() }, + beforeProjectionApplyTestGate = { pending.pause() }, + beforeProjectionReadinessWaitTestGate = { readiness.pause() }, + ) + + try { + val collection = async(Dispatchers.Default) { + harness.engine.stream(Freshness.LocalOnly).collect { } + } + pending.awaitEntered() + readiness.awaitEntered() + harness.close() + readiness.release() + val failure = runCatching { awaitFromDefault { collection.await() } }.exceptionOrNull() + assertIs(failure) + } finally { + pending.release() + readiness.release() + harness.close() + } + } + + @Test + fun throwingApplyTerminalizesCurrentAndFutureStreams() = runTest { + val boom = IllegalStateException("apply failed") + val store = store { + fetcher { awaitCancellation() } + overlay(ThrowingOverlay(boom, emptyFlow())) + } + + try { + assertTerminalCause(store, boom) + assertTerminalCause(store, boom) + } finally { + store.close() + } + } + + @Test + fun selfOriginatedCancellationFromApplyTerminalizesWhileEngineIsActive() = runTest { + val boom = CancellationException("callback cancelled itself") + val store = store { + fetcher { awaitCancellation() } + overlay(ThrowingOverlay(boom, emptyFlow())) + } + + try { + assertTerminalCause(store, boom) + } finally { + store.close() + } + } + + @Test + fun failingChangesTerminalizesCurrentAndFutureStreams() = runTest { + val boom = IllegalStateException("changes failed") + val store = store { + fetcher { awaitCancellation() } + overlay(ThrowingOverlay(null, flow { throw boom })) + } + + try { + assertTerminalCause(store, boom) + assertTerminalCause(store, boom) + } finally { + store.close() + } + } + + @Test + fun selfOriginatedCancellationFromChangesTerminalizesWhileEngineIsActive() = runTest { + val boom = CancellationException("changes cancelled itself") + val store = store { + fetcher { awaitCancellation() } + overlay(ThrowingOverlay(null, flow { throw boom })) + } + + try { + assertTerminalCause(store, boom) + } finally { + store.close() + } + } + + @Test + fun normalChangesCompletion_keepsResidenceProjectionActive() = runTest { + val overlay = CountingOverlay(changes = emptyFlow()) { base -> base?.plus("+op") } + val store = store { + fetcher { awaitCancellation() } + overlay(overlay) + } + + try { + store.stream(key).test { + assertIs(awaitItem()) + store.runtime()!!.writeHandle.apply(key, "confirmed") + val data = awaitDataValue("confirmed+op") + assertEquals(Origin.OVERLAY, data.origin) + cancelAndIgnoreRemainingEvents() + } + } finally { + store.close() + } + } + + @Test + fun rapidSignals_haveExactAcceptedCountsIncludingEqualOutputAndMismatch() = runTest { + val overlay = CountingOverlay({ "stable" }) + val source = ContractSourceOfTruth() + val harness = + stringEngine( + overlay = overlay, + sot = source, + fetcher = { awaitCancellation() }, + ) + + try { + harness.engine.applyWrite("base") + overlay.awaitCallValue("base") + overlay.clearCalls() + harness.engine.stream(Freshness.LocalOnly).test { + awaitDataValue("stable") + source.readerStarted.await() + overlay.awaitCallValue("base") + overlay.clearCalls() + val callsBeforeSignals = overlay.callCount + + overlay.signals.emit(TestKey("different")) + overlay.signals.emit(key) + assertEquals("base", overlay.awaitCall()) + expectNoEvents() // equal projection still invokes apply but emits no duplicate Data + + overlay.signals.emit(key) + assertEquals("base", overlay.awaitCall()) + expectNoEvents() + assertEquals(callsBeforeSignals + 2, overlay.callCount) + cancelAndIgnoreRemainingEvents() + } + } finally { + harness.close() + } + } + + @Test + fun twoCollectors_doNotIncreaseGlobalApplyCount() = runTest { + val overlay = CountingOverlay({ "one-global-projection" }) + val source = ContractSourceOfTruth() + val harness = + stringEngine( + overlay = overlay, + sot = source, + fetcher = { awaitCancellation() }, + ) + + try { + harness.engine.applyWrite("base") + overlay.awaitCallValue("base") + overlay.clearCalls() + turbineScope { + val first = harness.engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("one-global-projection", first.awaitDataValue().value) + source.readerStarted.await() + overlay.awaitCallValue("base") + overlay.clearCalls() + overlay.signals.emit(key) + assertEquals("base", overlay.awaitCall()) + val callsBeforeSecondCollector = overlay.callCount + + val second = harness.engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + assertEquals("one-global-projection", second.awaitDataValue().value) + overlay.clearCalls() + overlay.signals.emit(key) + assertEquals("base", overlay.awaitCall()) + assertEquals(callsBeforeSecondCollector + 1, overlay.callCount) + first.cancelAndIgnoreRemainingEvents() + second.cancelAndIgnoreRemainingEvents() + } + } finally { + harness.close() + } + } + + @Test + fun nullOverlay_preservesLandedBehaviorWithoutProjectionCallbacks() = runTest { + val neverInstalled = CountingOverlay({ error("must never run") }) + val plain = store { fetcher { "v" } } + + try { + plain.stream(key).test { + assertIs(awaitItem()) + val data = assertIs>(awaitItem()) + assertEquals("v", data.value) + assertEquals(Origin.FETCHER, data.origin) + expectNoEvents() + assertEquals(0, neverInstalled.callCount) + cancelAndIgnoreRemainingEvents() + } + } finally { + plain.close() + } + } + + private suspend fun TestScope.revalidationScenario( + overlay: CountingOverlay, + telemetry: RecordingTelemetry, + beforeRelease: suspend () -> Unit = {}, + verify: suspend (ReceiveTurbine>) -> Unit, + ) { + var calls = 0 + val gate = SuspendGate() + val store = store { + fetcherOfResult { + when (++calls) { + 1 -> FetcherResult.Success("v", etag = "e1") + 2 -> { + gate.pause() + FetcherResult.NotModified("e2") + } + else -> error("unexpected fetch $calls") + } + } + overlay(overlay) + telemetry(telemetry) + } + + try { + store.stream(key).test { + awaitDataValue() + telemetry.awaitServeCausally() + telemetry.clear() + store.invalidate(key) + gate.awaitEnteredCausally() + beforeRelease() + gate.release() + verify(this) + cancelAndIgnoreRemainingEvents() + } + } finally { + gate.release() + store.close() + } + } + + private suspend fun TestScope.failedProjectionScenario( + projectAbsent: Boolean, + verify: (List) -> Unit, + ) { + var calls = 0 + var latest = false + val fetchGate = SuspendGate() + val projectionGate = SuspendGate() + var blockLatest = false + val boom = IllegalStateException("fetch failed") + val overlay = CountingOverlay({ base -> + when { + !latest -> base?.plus("+initial") + projectAbsent -> null + else -> base?.plus("+latest") + } + }) + val harness = + stringEngine( + overlay = overlay, + fetcher = { + when (++calls) { + 1 -> FetcherResult.Success("v") + 2 -> { + fetchGate.pause() + FetcherResult.Error(boom) + } + else -> error("unexpected fetch $calls") + } + }, + afterProjectionApplyTestGate = { base -> + if (base == "v" && blockLatest) { + blockLatest = false + projectionGate.pause() + } + }, + ) + + try { + harness.engine.stream(Freshness.StaleIfError).test { + awaitDataValue("v+initial") + harness.engine.invalidate() + fetchGate.awaitEntered() + latest = true + blockLatest = true + overlay.signals.emit(key) + projectionGate.awaitEntered() + fetchGate.release() + projectionGate.release() + + val events = mutableListOf() + while (events.none { it.startsWith("error:") }) { + when (val item = awaitItem()) { + is StoreResult.Data -> events += "data:${item.value}" + is StoreResult.Loading -> events += "loading" + is StoreResult.Error -> events += "error:${item.servedStale}" + is StoreResult.Revalidated -> events += "revalidated" + } + } + verify(events) + cancelAndIgnoreRemainingEvents() + } + } finally { + fetchGate.release() + projectionGate.release() + harness.close() + } + } + + private fun TestScope.refEngine( + overlay: Overlay?, + fetcher: suspend (TestKey) -> FetcherResult, + sot: SourceOfTruth = SharedFlowSourceOfTruth(), + beforeReaderRecordMappingTestGate: suspend () -> Unit = {}, + beforeReaderDeliveryLockTestGate: suspend (ReaderRecord) -> Unit = {}, + beforeProjectionApplyTestGate: suspend (RefValue?) -> Unit = {}, + afterProjectionApplyTestGate: suspend (RefValue?) -> Unit = {}, + beforeReaderDeliveryTestGate: suspend () -> Unit = {}, + beforeTicketOutcomeDeliveryTestGate: suspend () -> Unit = {}, + beforeProjectionDeliveryLockTestGate: suspend () -> Unit = {}, + beforeProjectionDeliveryTestGate: suspend () -> Unit = {}, + afterProjectionDeliveryTestGate: suspend () -> Unit = {}, + beforeProjectionReadinessWaitTestGate: suspend () -> Unit = {}, + telemetry: StoreTelemetry? = null, + ): EngineHarness = + engineTyped( + overlay, + fetcher, + sot, + beforeReaderRecordMappingTestGate, + beforeReaderDeliveryLockTestGate, + beforeProjectionApplyTestGate, + afterProjectionApplyTestGate, + beforeReaderDeliveryTestGate, + beforeTicketOutcomeDeliveryTestGate, + beforeProjectionDeliveryLockTestGate, + beforeProjectionDeliveryTestGate, + afterProjectionDeliveryTestGate, + beforeProjectionReadinessWaitTestGate, + telemetry, + ) + + private fun TestScope.stringEngine( + overlay: Overlay?, + fetcher: suspend (TestKey) -> FetcherResult, + sot: SourceOfTruth = SharedFlowSourceOfTruth(), + beforeReaderRecordMappingTestGate: suspend () -> Unit = {}, + beforeReaderDeliveryLockTestGate: suspend (ReaderRecord) -> Unit = {}, + beforeProjectionApplyTestGate: suspend (String?) -> Unit = {}, + afterProjectionApplyTestGate: suspend (String?) -> Unit = {}, + beforeReaderDeliveryTestGate: suspend () -> Unit = {}, + beforeTicketOutcomeDeliveryTestGate: suspend () -> Unit = {}, + beforeProjectionDeliveryLockTestGate: suspend () -> Unit = {}, + beforeProjectionDeliveryTestGate: suspend () -> Unit = {}, + afterProjectionDeliveryTestGate: suspend () -> Unit = {}, + beforeProjectionReadinessWaitTestGate: suspend () -> Unit = {}, + telemetry: StoreTelemetry? = null, + ): EngineHarness = + engineTyped( + overlay, + fetcher, + sot, + beforeReaderRecordMappingTestGate, + beforeReaderDeliveryLockTestGate, + beforeProjectionApplyTestGate, + afterProjectionApplyTestGate, + beforeReaderDeliveryTestGate, + beforeTicketOutcomeDeliveryTestGate, + beforeProjectionDeliveryLockTestGate, + beforeProjectionDeliveryTestGate, + afterProjectionDeliveryTestGate, + beforeProjectionReadinessWaitTestGate, + telemetry, + ) + + private fun TestScope.engineTyped( + overlay: Overlay?, + fetcher: suspend (TestKey) -> FetcherResult, + sot: SourceOfTruth, + beforeReaderRecordMappingTestGate: suspend () -> Unit, + beforeReaderDeliveryLockTestGate: suspend (ReaderRecord) -> Unit, + beforeProjectionApplyTestGate: suspend (V?) -> Unit, + afterProjectionApplyTestGate: suspend (V?) -> Unit, + beforeReaderDeliveryTestGate: suspend () -> Unit, + beforeTicketOutcomeDeliveryTestGate: suspend () -> Unit, + beforeProjectionDeliveryLockTestGate: suspend () -> Unit, + beforeProjectionDeliveryTestGate: suspend () -> Unit, + afterProjectionDeliveryTestGate: suspend () -> Unit, + beforeProjectionReadinessWaitTestGate: suspend () -> Unit, + telemetry: StoreTelemetry?, + ): EngineHarness { + val job = SupervisorJob(backgroundScope.coroutineContext[Job]) + val scope = CoroutineScope(Dispatchers.Default + job) + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher(fetcher), + sot = sot, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = org.mobilenativefoundation.store6.core.FakeWallClock(0L), + engineScope = scope, + telemetry = telemetry, + beforeReaderRecordMappingTestGate = beforeReaderRecordMappingTestGate, + beforeReaderDeliveryLockTestGate = beforeReaderDeliveryLockTestGate, + beforeReaderDeliveryTestGate = beforeReaderDeliveryTestGate, + beforeTicketOutcomeDeliveryTestGate = beforeTicketOutcomeDeliveryTestGate, + overlay = overlay, + beforeProjectionApplyTestGate = beforeProjectionApplyTestGate, + afterProjectionApplyTestGate = afterProjectionApplyTestGate, + beforeProjectionDeliveryLockTestGate = beforeProjectionDeliveryLockTestGate, + beforeProjectionDeliveryTestGate = beforeProjectionDeliveryTestGate, + afterProjectionDeliveryTestGate = afterProjectionDeliveryTestGate, + beforeProjectionReadinessWaitTestGate = beforeProjectionReadinessWaitTestGate, + ) + return EngineHarness(engine, job) + } + + private suspend fun assertTerminalCause( + store: org.mobilenativefoundation.store6.core.Store, + expected: Throwable, + ) { + val failure = runCatching { store.stream(key, Freshness.LocalOnly).collect { } }.exceptionOrNull() + val terminal = assertIs(failure) + assertEquals(expected::class, terminal.cause!!::class) + assertEquals(expected.message, terminal.cause?.message) + } + + private suspend fun ReceiveTurbine>.awaitDataValue( + expected: String? = null, + ): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + seenDataValues += item.value + if (expected == null || item.value == expected) return item + } + } + } + + private val seenDataValues = mutableListOf() + + private val seenDataTags = mutableListOf() + + private suspend fun ReceiveTurbine>.awaitDataTag( + expected: String, + ): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + seenDataTags += item.value.tag + if (item.value.tag == expected) return item + } + } + } + + private suspend fun ReceiveTurbine>.awaitRefData(): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + seenDataTags += item.value.tag + return item + } + } + } + + private suspend fun ReceiveTurbine>.awaitNonDataTerminal(): StoreResult { + while (true) { + when (val item = awaitItem()) { + is StoreResult.Data -> + error( + "unexpected extra Data(" + + "value=${item.value}, origin=${item.origin}, age=${item.age}, " + + "isStale=${item.isStale}, refreshing=${item.refreshing})", + ) + is StoreResult.Revalidated, + is StoreResult.Error, + -> return item + is StoreResult.Loading -> Unit + } + } + } + + // Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above + // the shadow makes runTest the only effective timeout. + private val TEST_TIMEOUT = 25.seconds + private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + + private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } + + // Keep Default-dispatch ordering, but let runTest provide the only timeout. Short nested + // wall-clock deadlines are scheduler-sensitive under the broad root build graph. + private suspend fun awaitFromDefault(block: suspend () -> T): T = + withContext(Dispatchers.Default) { + block() + } + + private class SuspendGate { + private val entered = CompletableDeferred() + private val released = CompletableDeferred() + private val exited = CompletableDeferred() + + suspend fun pause() { + entered.complete(Unit) + try { + released.await() + } finally { + exited.complete(Unit) + } + } + + suspend fun awaitEntered() { + withContext(Dispatchers.Default) { + entered.await() + } + } + + suspend fun awaitEnteredCausally() { + entered.await() + } + + suspend fun awaitExited() { + withContext(Dispatchers.Default) { + exited.await() + } + } + + fun release() { + released.complete(Unit) + } + } + + private class CountingOverlay( + var transform: (V?) -> V?, + private val suppliedChanges: Flow? = null, + ) : Overlay { + constructor( + changes: Flow, + transform: (V?) -> V?, + ) : this(transform, changes) + + val signals = MutableSharedFlow(replay = 1) + private val calls = Channel(Channel.UNLIMITED) + var callCount: Int = 0 + private set + + override fun apply( + key: TestKey, + base: V?, + ): V? { + callCount += 1 + check(calls.trySend(base).isSuccess) + return transform(base) + } + + override val changes: Flow + get() = suppliedChanges ?: signals + + suspend fun awaitCall(): V? = + withContext(Dispatchers.Default) { + calls.receive() + } + + suspend fun awaitCallValue(expected: V) { + while (awaitCall() != expected) Unit + } + + fun clearCalls() { + while (calls.tryReceive().isSuccess) Unit + } + } + + private class ThrowingOverlay( + private val applyFailure: Throwable?, + override val changes: Flow, + ) : Overlay { + override fun apply( + key: TestKey, + base: String?, + ): String? { + applyFailure?.let { throw it } + return base + } + } + + private class FailingReaderSourceOfTruth( + private val failure: Throwable, + private val liveReaderFailure: SuspendGate? = null, + ) : SourceOfTruth { + private val startupCollection = CompletableDeferred() + + override fun reader(key: TestKey): Flow = flow { + if (!startupCollection.complete(Unit)) liveReaderFailure?.pause() + throw failure + } + + override suspend fun write( + key: TestKey, + value: String, + ) = Unit + + override suspend fun delete(key: TestKey) = Unit + + override suspend fun deleteNamespace(namespace: org.mobilenativefoundation.store6.core.StoreNamespace) = Unit + + override suspend fun deleteAll() = Unit + } + + /** Contract-honoring replay source with deterministic reader enrollment. */ + private class ContractSourceOfTruth : SourceOfTruth { + private val rows = MutableSharedFlow(replay = 1).also { check(it.tryEmit(null)) } + val readerStarted = CompletableDeferred() + + override fun reader(key: TestKey): Flow = flow { + readerStarted.complete(Unit) + emitAll(rows) + } + + override suspend fun write( + key: TestKey, + value: V, + ) { + rows.emit(value) + } + + override suspend fun delete(key: TestKey) { + rows.emit(null) + } + + override suspend fun deleteNamespace(namespace: org.mobilenativefoundation.store6.core.StoreNamespace) { + rows.emit(null) + } + + override suspend fun deleteAll() { + rows.emit(null) + } + } + + private class RecordingTelemetry : StoreTelemetry { + val serves = mutableListOf() + private val serveEvents = Channel(Channel.UNLIMITED) + + override fun onServe( + key: StoreKey, + origin: Origin, + ) { + serves += origin + check(serveEvents.trySend(origin).isSuccess) + } + + suspend fun awaitServe(expected: Origin? = null): Origin { + while (true) { + val observed = + withContext(Dispatchers.Default) { + serveEvents.receive() + } + if (expected == null || observed == expected) return observed + } + } + + suspend fun awaitServeCausally(expected: Origin? = null): Origin { + while (true) { + val observed = serveEvents.receive() + if (expected == null || observed == expected) return observed + } + } + + fun clear() { + serves.clear() + while (serveEvents.tryReceive().isSuccess) Unit + } + } + + private data class RefValue( + val id: String, + val tag: String, + ) + + private enum class ForeignOwnerOrdering { + READER_FIRST, + OWNER_FIRST, + } + + private class EngineHarness( + val engine: KeyEngine, + private val job: Job, + ) { + fun close() { + job.cancel() + } + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionAuthorizationHandoffTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionAuthorizationHandoffTest.kt new file mode 100644 index 000000000..b5cb136a1 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionAuthorizationHandoffTest.kt @@ -0,0 +1,1029 @@ +package org.mobilenativefoundation.store6.core.internal + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.testIn +import app.cash.turbine.turbineScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.FakeWallClock +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.SingleRowTestSourceOfTruth +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.TestKey +import org.mobilenativefoundation.store6.core.seam.Overlay +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class, ExperimentalCoroutinesApi::class) +class ProjectionAuthorizationHandoffTest { + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = true, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = true, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = true, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingBeforeConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = true, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryBeforeConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = true, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementBeforeConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = true, + successorPresent = true, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorAbsent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = false, + invalidationInterleaved = true, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationAbsent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = false, + ) + + @Test + fun mappingAfterConfirmFresh_deliveryAfterConfirmFresh_retirementAfterConfirmFresh_successorPresent_invalidationPresent() = + runMatrixCell( + mappingBeforeConfirmFresh = false, + deliveryBeforeConfirmFresh = false, + retirementBeforeConfirmFresh = false, + successorPresent = true, + invalidationInterleaved = true, + ) + + private fun runMatrixCell( + mappingBeforeConfirmFresh: Boolean, + deliveryBeforeConfirmFresh: Boolean, + retirementBeforeConfirmFresh: Boolean, + successorPresent: Boolean, + invalidationInterleaved: Boolean, + ) = runTest { + val harness = matrixHarness("projection-authorization-matrix") + assertEquals("seed", harness.engine.get(Freshness.LocalOnly)) + + turbineScope { + val observer = harness.engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + try { + settleInitialProjection(harness, observer) + + val firstMapping = harness.mappingGate.gateNext() + val firstDelivery = harness.readerDeliveryGate.gateNext() + val firstWrite = harness.sourceOfTruth.gateNextWrite() + val firstResidenceProjection = harness.afterProjectionDeliveryGate.gateNext() + harness.overlay.clearCalls() + val apply = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + harness.engine.applyWrite("echo") + } + + firstMapping.entered.await() + firstWrite.published.await() + assertFalse(firstDelivery.entered.isCompleted) + + firstWrite.releaseReturn.complete(Unit) + apply.await() + assertProjectionCall( + call = harness.overlay.awaitCall(), + retired = false, + ) + firstResidenceProjection.entered.await() + firstResidenceProjection.releaseAndAwait() + testScheduler.runCurrent() + observer.expectNoEvents() + + val invalidateBeforePredecessorDelivery = + invalidationInterleaved && + mappingBeforeConfirmFresh && + deliveryBeforeConfirmFresh + if (invalidateBeforePredecessorDelivery) { + assertTrue(firstMapping.entered.isCompleted, "raw row was captured") + assertFalse(firstDelivery.entered.isCompleted) + harness.engine.invalidate() + testScheduler.runCurrent() + observer.expectNoEvents() + } + + if (mappingBeforeConfirmFresh) { + firstMapping.releaseAndAwait() + testScheduler.runCurrent() + assertTrue( + firstDelivery.entered.isCompleted, + "mapping-before must make the first delivery gate causally reachable", + ) + } else { + assertFalse(firstMapping.exited.isCompleted) + } + + if (deliveryBeforeConfirmFresh) { + if (mappingBeforeConfirmFresh) { + firstDelivery.entered.await() + assertTrue(firstDelivery.entered.isCompleted) + firstDelivery.releaseAndAwait() + testScheduler.runCurrent() + } else { + firstDelivery.requestRelease() + testScheduler.runCurrent() + assertFalse( + firstDelivery.entered.isCompleted, + "delivery cannot enter while its upstream mapping is held", + ) + } + } else if (mappingBeforeConfirmFresh) { + firstDelivery.entered.await() + assertFalse(firstDelivery.exited.isCompleted) + } + + if (retirementBeforeConfirmFresh) { + harness.overlay.clearCalls() + harness.overlay.retire(harness.key) + assertProjectionCall( + call = harness.overlay.awaitCall(), + retired = true, + ) + testScheduler.runCurrent() + if (mappingBeforeConfirmFresh && deliveryBeforeConfirmFresh) { + assertPredecessorConfirmed(observer) + } else { + observer.expectNoEvents() + } + } else { + observer.expectNoEvents() + } + + if (invalidationInterleaved && !invalidateBeforePredecessorDelivery) { + assertTrue(firstMapping.entered.isCompleted, "raw row was captured") + assertFalse( + firstDelivery.exited.isCompleted, + "invalidation must remain between raw capture and collector delivery", + ) + // Do not advance the scheduler before confirmFresh: the stale-epoch observer is + // itself a delivery path and must resolve the post-confirmFresh residence. + harness.engine.invalidate() + } + val heldR2 = + if (successorPresent) { + harness.beforeProjectionDeliveryGate.gateNext() + } else { + null + } + harness.engine.confirmFresh(etag = "confirmed") + testScheduler.runCurrent() + + if (!mappingBeforeConfirmFresh) { + firstMapping.releaseAndAwait() + testScheduler.runCurrent() + firstDelivery.entered.await() + assertTrue( + firstDelivery.entered.isCompleted, + "mapping release after confirmFresh must make delivery reachable", + ) + } + + if (!deliveryBeforeConfirmFresh) { + firstDelivery.entered.await() + assertFalse(firstDelivery.exited.isCompleted) + firstDelivery.releaseAndAwait() + } else if (!mappingBeforeConfirmFresh) { + firstDelivery.exited.await() + } + testScheduler.runCurrent() + + if (successorPresent) { + runSuccessorCell( + harness = harness, + observer = observer, + heldR2 = checkNotNull(heldR2), + retirementBeforeConfirmFresh = retirementBeforeConfirmFresh, + predecessorDeliveredBeforeConfirm = + mappingBeforeConfirmFresh && deliveryBeforeConfirmFresh, + ) + } else { + finishWithoutSuccessor( + harness = harness, + observer = observer, + retirementBeforeConfirmFresh = retirementBeforeConfirmFresh, + ) + } + } finally { + harness.releaseAll() + observer.cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun consecutiveConfirmFresh_coalescedReadyPreservesStableAuthorizationLineage() = runTest { + val harness = matrixHarness("projection-authorization-consecutive-confirm") + assertEquals("seed", harness.engine.get(Freshness.LocalOnly)) + + turbineScope { + val observer = harness.engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + try { + settleInitialProjection(harness, observer) + authorizeR1(harness, observer) + + harness.overlay.clearCalls() + val heldR2 = harness.beforeProjectionDeliveryGate.gateNext() + harness.engine.confirmFresh(etag = "r2") + heldR2.entered.await() + assertProjectionCall(harness.overlay.awaitCall(), retired = false) + + harness.overlay.clearCalls() + harness.engine.confirmFresh(etag = "r3") + assertProjectionCall(harness.overlay.awaitCall(), retired = false) + testScheduler.runCurrent() + + val heldR3 = harness.beforeProjectionDeliveryGate.gateNext() + val staleR2Delivered = harness.afterProjectionDeliveryGate.gateNext() + heldR2.releaseAndAwait() + staleR2Delivered.entered.await() + observer.expectNoEvents() + staleR2Delivered.releaseAndAwait() + + heldR3.entered.await() + val currentR3Delivered = harness.afterProjectionDeliveryGate.gateNext() + heldR3.releaseAndAwait() + currentR3Delivered.entered.await() + observer.expectNoEvents() + currentR3Delivered.releaseAndAwait() + + val retirementDelivered = harness.afterProjectionDeliveryGate.gateNext() + harness.overlay.clearCalls() + harness.overlay.retire(harness.key) + assertProjectionCall(harness.overlay.awaitCall(), retired = true) + retirementDelivered.entered.await() + assertLatestConfirmed(observer) + retirementDelivered.releaseAndAwait() + } finally { + harness.releaseAll() + observer.cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun readerAwaitingObsoleteR1Readiness_handsAuthorizationToConfirmFreshR2() = runTest { + val harness = matrixHarness("projection-authorization-obsolete-readiness") + assertEquals("seed", harness.engine.get(Freshness.LocalOnly)) + + turbineScope { + val observer = harness.engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + try { + settleInitialProjection(harness, observer) + + val r1Mapping = harness.mappingGate.gateNext() + val r1Write = harness.sourceOfTruth.gateNextWrite() + val heldR1Apply = harness.beforeProjectionApplyGate.gateNext() + harness.overlay.clearCalls() + val apply = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + harness.engine.applyWrite("echo") + } + r1Mapping.entered.await() + r1Write.published.await() + r1Write.releaseReturn.complete(Unit) + apply.await() + heldR1Apply.entered.await() + + val heldReadiness = harness.projectionReadinessGate.gateNext() + r1Mapping.releaseAndAwait() + heldReadiness.entered.await() + harness.engine.confirmFresh(etag = "r2") + heldReadiness.releaseAndAwait() + testScheduler.runCurrent() + observer.expectNoEvents() + + val r2Delivered = harness.afterProjectionDeliveryGate.gateNext() + heldR1Apply.releaseAndAwait() + assertProjectionCall(harness.overlay.awaitCall(), retired = false) + assertProjectionCall(harness.overlay.awaitCall(), retired = false) + r2Delivered.entered.await() + observer.expectNoEvents() + r2Delivered.releaseAndAwait() + + val retirementDelivered = harness.afterProjectionDeliveryGate.gateNext() + harness.overlay.clearCalls() + harness.overlay.retire(harness.key) + assertProjectionCall(harness.overlay.awaitCall(), retired = true) + retirementDelivered.entered.await() + assertLatestConfirmed(observer) + retirementDelivered.releaseAndAwait() + } finally { + harness.releaseAll() + observer.cancelAndIgnoreRemainingEvents() + } + } + } + + @Test + fun confirmFreshBetweenResolutionAndAuthorization_handsCapturedR1LineageToR2() = runTest { + val harness = matrixHarness("projection-authorization-resolve-factory-race") + assertEquals("seed", harness.engine.get(Freshness.LocalOnly)) + + turbineScope { + val observer = harness.engine.stream(Freshness.LocalOnly).testIn(backgroundScope) + try { + settleInitialProjection(harness, observer) + + val mapping = harness.mappingGate.gateNext() + val delivery = harness.readerDeliveryGate.gateNext() + val write = harness.sourceOfTruth.gateNextWrite() + val residenceProjection = harness.afterProjectionDeliveryGate.gateNext() + harness.overlay.clearCalls() + val apply = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + harness.engine.applyWrite("echo") + } + mapping.entered.await() + write.published.await() + write.releaseReturn.complete(Unit) + apply.await() + assertProjectionCall(harness.overlay.awaitCall(), retired = false) + residenceProjection.entered.await() + residenceProjection.releaseAndAwait() + + val authorization = harness.projectionAuthorizationGate.gateNext() + mapping.releaseAndAwait() + delivery.entered.await() + delivery.releaseAndAwait() + authorization.entered.await() + + harness.engine.confirmFresh(etag = "r2") + authorization.releaseAndAwait() + testScheduler.runCurrent() + observer.expectNoEvents() + + val retirementDelivered = harness.afterProjectionDeliveryGate.gateNext() + harness.overlay.clearCalls() + harness.overlay.retire(harness.key) + assertProjectionCall(harness.overlay.awaitCall(), retired = true) + retirementDelivered.entered.await() + assertLatestConfirmed(observer) + retirementDelivered.releaseAndAwait() + } finally { + harness.releaseAll() + observer.cancelAndIgnoreRemainingEvents() + } + } + } + + private suspend fun TestScope.runSuccessorCell( + harness: MatrixHarness, + observer: ReceiveTurbine>, + heldR2: SequencedGate.Step, + retirementBeforeConfirmFresh: Boolean, + predecessorDeliveredBeforeConfirm: Boolean, + ) { + heldR2.entered.await() + testScheduler.runCurrent() + if (retirementBeforeConfirmFresh && !predecessorDeliveredBeforeConfirm) { + assertPredecessorConfirmed(observer) + } else { + observer.expectNoEvents() + } + + val successorMapping = harness.mappingGate.gateNext() + val successorDelivery = harness.readerDeliveryGate.gateNext() + val successorWrite = harness.sourceOfTruth.gateNextWrite() + harness.overlay.clearCalls() + val successor = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + harness.engine.applyWrite("echo") + } + successorMapping.entered.await() + successorWrite.published.await() + successorWrite.releaseReturn.complete(Unit) + successor.await() + assertProjectionCall( + call = harness.overlay.awaitCall(), + retired = retirementBeforeConfirmFresh, + ) + + if (!retirementBeforeConfirmFresh) { + harness.overlay.clearCalls() + harness.overlay.retire(harness.key) + assertProjectionCall(harness.overlay.awaitCall(), retired = true) + } + testScheduler.runCurrent() + assertFalse(successorMapping.exited.isCompleted) + assertFalse(successorDelivery.entered.isCompleted) + + val heldR3 = harness.beforeProjectionDeliveryGate.gateNext() + val staleR2Delivered = harness.afterProjectionDeliveryGate.gateNext() + heldR2.releaseAndAwait() + staleR2Delivered.entered.await() + observer.expectNoEvents() + staleR2Delivered.releaseAndAwait() + + heldR3.entered.await() + val unauthorizedR3Delivered = harness.afterProjectionDeliveryGate.gateNext() + heldR3.releaseAndAwait() + unauthorizedR3Delivered.entered.await() + observer.expectNoEvents() + unauthorizedR3Delivered.releaseAndAwait() + + assertFalse(successorDelivery.entered.isCompleted) + successorMapping.releaseAndAwait() + testScheduler.runCurrent() + successorDelivery.entered.await() + observer.expectNoEvents() + successorDelivery.releaseAndAwait() + testScheduler.runCurrent() + assertLatestConfirmed(observer) + } + + private suspend fun TestScope.finishWithoutSuccessor( + harness: MatrixHarness, + observer: ReceiveTurbine>, + retirementBeforeConfirmFresh: Boolean, + ) { + if (!retirementBeforeConfirmFresh) { + observer.expectNoEvents() + val retirementDelivered = harness.afterProjectionDeliveryGate.gateNext() + harness.overlay.clearCalls() + harness.overlay.retire(harness.key) + assertProjectionCall(harness.overlay.awaitCall(), retired = true) + retirementDelivered.entered.await() + assertLatestConfirmed(observer) + retirementDelivered.releaseAndAwait() + } else { + testScheduler.runCurrent() + assertLatestConfirmed(observer) + } + } + + private suspend fun TestScope.authorizeR1( + harness: MatrixHarness, + observer: ReceiveTurbine>, + ) { + val mapping = harness.mappingGate.gateNext() + val delivery = harness.readerDeliveryGate.gateNext() + val write = harness.sourceOfTruth.gateNextWrite() + val residenceProjection = harness.afterProjectionDeliveryGate.gateNext() + harness.overlay.clearCalls() + val apply = + backgroundScope.async(start = CoroutineStart.UNDISPATCHED) { + harness.engine.applyWrite("echo") + } + mapping.entered.await() + write.published.await() + write.releaseReturn.complete(Unit) + apply.await() + assertProjectionCall(harness.overlay.awaitCall(), retired = false) + residenceProjection.entered.await() + residenceProjection.releaseAndAwait() + mapping.releaseAndAwait() + delivery.entered.await() + delivery.releaseAndAwait() + testScheduler.runCurrent() + observer.expectNoEvents() + } + + private suspend fun TestScope.settleInitialProjection( + harness: MatrixHarness, + observer: ReceiveTurbine>, + ) { + testScheduler.runCurrent() + val initial = assertIs>(observer.expectMostRecentItem()) + assertEquals("optimistic", initial.value) + assertEquals(Origin.OVERLAY, initial.origin) + harness.sourceOfTruth.liveReaderStarted.await() + testScheduler.runCurrent() + harness.overlay.clearCalls() + observer.expectNoEvents() + } + + private fun TestScope.matrixHarness(id: String): MatrixHarness { + val key = TestKey(id) + val sourceOfTruth = MatrixSourceOfTruth() + val overlay = MatrixOverlay() + val mappingGate = SequencedGate() + val readerDeliveryGate = SequencedGate() + val beforeProjectionApplyGate = SequencedGate() + val beforeProjectionDeliveryGate = SequencedGate() + val afterProjectionDeliveryGate = SequencedGate() + val projectionReadinessGate = SequencedGate() + val projectionAuthorizationGate = SequencedGate() + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { + error("projection authorization matrix must never fetch") + }, + sot = sourceOfTruth, + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(now = 0L), + engineScope = backgroundScope, + beforeReaderRecordMappingTestGate = mappingGate::awaitIfQueued, + beforeReaderDeliveryTestGate = readerDeliveryGate::awaitIfQueued, + overlay = overlay, + beforeProjectionApplyTestGate = { beforeProjectionApplyGate.awaitIfQueued() }, + beforeProjectionDeliveryLockTestGate = + beforeProjectionDeliveryGate::awaitIfQueued, + afterProjectionDeliveryTestGate = afterProjectionDeliveryGate::awaitIfQueued, + beforeProjectionReadinessWaitTestGate = + projectionReadinessGate::awaitIfQueued, + beforeProjectionAuthorizationTestGate = + projectionAuthorizationGate::awaitIfQueued, + ) + return MatrixHarness( + key = key, + engine = engine, + sourceOfTruth = sourceOfTruth, + overlay = overlay, + mappingGate = mappingGate, + readerDeliveryGate = readerDeliveryGate, + beforeProjectionApplyGate = beforeProjectionApplyGate, + beforeProjectionDeliveryGate = beforeProjectionDeliveryGate, + afterProjectionDeliveryGate = afterProjectionDeliveryGate, + projectionReadinessGate = projectionReadinessGate, + projectionAuthorizationGate = projectionAuthorizationGate, + ) + } + + private fun assertProjectionCall( + call: MatrixOverlay.ApplyCall, + retired: Boolean, + ) { + assertEquals("echo", call.base) + assertEquals(retired, call.retired) + } + + private fun assertPredecessorConfirmed( + observer: ReceiveTurbine>, + ) { + val data = assertIs>(observer.expectMostRecentItem()) + assertEquals("echo", data.value) + assertEquals(Origin.SOT, data.origin) + } + + private fun assertLatestConfirmed( + observer: ReceiveTurbine>, + ): StoreResult.Data { + val data = assertIs>(observer.expectMostRecentItem()) + assertEquals("echo", data.value) + assertEquals(Origin.SOT, data.origin) + assertFalse(data.isStale) + assertFalse(data.refreshing) + return data + } + + private class MatrixHarness( + val key: TestKey, + val engine: KeyEngine, + val sourceOfTruth: MatrixSourceOfTruth, + val overlay: MatrixOverlay, + val mappingGate: SequencedGate, + val readerDeliveryGate: SequencedGate, + val beforeProjectionApplyGate: SequencedGate, + val beforeProjectionDeliveryGate: SequencedGate, + val afterProjectionDeliveryGate: SequencedGate, + val projectionReadinessGate: SequencedGate, + val projectionAuthorizationGate: SequencedGate, + ) { + fun releaseAll() { + sourceOfTruth.releaseAll() + mappingGate.releaseAll() + readerDeliveryGate.releaseAll() + beforeProjectionApplyGate.releaseAll() + beforeProjectionDeliveryGate.releaseAll() + afterProjectionDeliveryGate.releaseAll() + projectionReadinessGate.releaseAll() + projectionAuthorizationGate.releaseAll() + } + } + + private class SequencedGate { + private val queued = ArrayDeque() + private val allSteps = mutableListOf() + + fun gateNext(): Step = + Step().also { step -> + queued.addLast(step) + allSteps += step + } + + suspend fun awaitIfQueued() { + val step = queued.removeFirstOrNull() ?: return + step.entered.complete(Unit) + try { + step.awaitRelease() + } finally { + step.exited.complete(Unit) + } + } + + fun releaseAll() { + allSteps.forEach { it.requestRelease() } + } + + class Step { + val entered = CompletableDeferred() + private val release = CompletableDeferred() + val exited = CompletableDeferred() + + fun requestRelease() { + release.complete(Unit) + } + + suspend fun releaseAndAwait() { + requestRelease() + exited.await() + } + + suspend fun awaitRelease() { + release.await() + } + } + } + + private class MatrixSourceOfTruth : SingleRowTestSourceOfTruth { + private val liveRows = MutableSharedFlow() + private val queuedWrites = ArrayDeque() + private val allWrites = mutableListOf() + private var readerCalls = 0 + private var current: String? = "seed" + val liveReaderStarted = CompletableDeferred() + + fun gateNextWrite(): WriteStep = + WriteStep().also { step -> + queuedWrites.addLast(step) + allWrites += step + } + + override fun reader(key: TestKey): Flow { + readerCalls += 1 + val isLiveReader = readerCalls >= 2 + return flow { + if (isLiveReader) liveReaderStarted.complete(Unit) + emit(current) + if (isLiveReader) { + liveRows.collect { value -> emit(value) } + } + } + } + + override suspend fun write( + key: TestKey, + value: String, + ) { + val step = + queuedWrites.removeFirstOrNull() + ?: error("Every matrix write must install its deterministic return gate.") + current = value + liveRows.emit(value) + step.published.complete(Unit) + step.releaseReturn.await() + } + + override suspend fun delete(key: TestKey) { + current = null + liveRows.emit(null) + } + + fun releaseAll() { + allWrites.forEach { it.releaseReturn.complete(Unit) } + } + + class WriteStep { + val published = CompletableDeferred() + val releaseReturn = CompletableDeferred() + } + } + + private class MatrixOverlay : Overlay { + private val signals = MutableSharedFlow(replay = 1) + private val calls = Channel(Channel.UNLIMITED) + private var retired = false + + override fun apply( + key: TestKey, + base: String?, + ): String? { + check(calls.trySend(ApplyCall(base = base, retired = retired)).isSuccess) + return if (retired) base else "optimistic" + } + + override val changes: Flow = signals + + suspend fun retire(key: TestKey) { + check(!retired) + retired = true + signals.emit(key) + } + + suspend fun awaitCall(): ApplyCall = calls.receive() + + fun clearCalls() { + while (calls.tryReceive().isSuccess) Unit + } + + data class ApplyCall( + val base: String?, + val retired: Boolean, + ) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionRecomputeVisibilityProbeTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionRecomputeVisibilityProbeTest.kt new file mode 100644 index 000000000..7b840b35b --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ProjectionRecomputeVisibilityProbeTest.kt @@ -0,0 +1,306 @@ +package org.mobilenativefoundation.store6.core.internal + +import app.cash.turbine.test +import app.cash.turbine.withTurbineTimeout +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest as coroutineRunTest +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.FakeWallClock +import org.mobilenativefoundation.store6.core.Freshness +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.TestKey +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.seam.Overlay +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds + +/** + * Records whether a freshly attached reader receives the previous Ready snapshot while a + * projection recompute is pending for an accepted overlay change, then asserts convergence after + * the recompute completes. + * + * The probe reconstructs the alias-activation handoff's core-visible shape without the + * mutations machinery: an overlay whose projected value changes, a change signal accepted by + * the key's single writer, no residence-revision advance, and the recompute held at the + * engine's own projection-apply gate while a fresh collector attaches. The probe asserts + * convergence. The printed first-frame observations are measurements, not behavioral assertions. + */ +@OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) +class ProjectionRecomputeVisibilityProbeTest { + private val key = TestKey("recompute-visibility-probe") + + @Test + fun freshAttachDuringHeldRecompute_recordFirstFrameThenConverge() = runTest { + val signals = MutableSharedFlow(replay = 1) + val projected = MutableStateFlow("head") + val gateArmed = MutableStateFlow(false) + val gate = SuspendGate() + val overlay = + object : Overlay { + override val changes: Flow = signals + + override fun apply( + key: TestKey, + base: String?, + ): String = projected.value + } + val job = SupervisorJob(backgroundScope.coroutineContext[Job]) + val scope = CoroutineScope(Dispatchers.Default + job) + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("base") }, + sot = SharedFlowSourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(0L), + engineScope = scope, + telemetry = null, + overlay = overlay, + beforeProjectionApplyTestGate = { if (gateArmed.value) gate.pause() }, + ) + + try { + engine.stream(Freshness.CachedOrFetch).test { + awaitDataValue("head") + cancelAndIgnoreRemainingEvents() + } + println("PROBE032 phase1 initial projection 'head' observed") + withContext(Dispatchers.Default) { + signals.subscriptionCount.first { count -> count > 0 } + } + + projected.value = "head+tail" + gateArmed.value = true + signals.emit(key) + gate.awaitEntered() + println("PROBE032 phase2 recompute held at the apply gate") + + val frames = mutableListOf() + val firstFrame = CompletableDeferred() + val watchdog = + backgroundScope.launch(Dispatchers.Default) { + delay(3_000) + if (!firstFrame.isCompleted) { + println("PROBE032 measurement=NO_FIRST_FRAME_WHILE_HELD (attach waited out the 3s hold)") + gateArmed.value = false + gate.release() + } + } + engine.stream(Freshness.LocalOnly).test { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + frames += item.value + if (!firstFrame.isCompleted) { + firstFrame.complete(item.value) + if (gateArmed.value) { + println("PROBE032 measurement=FIRST_FRAME_WHILE_HELD value=${item.value}") + } + gateArmed.value = false + gate.release() + } + if (item.value == "head+tail") break + } else { + frames += item::class.simpleName.orEmpty() + } + } + cancelAndIgnoreRemainingEvents() + } + watchdog.cancel() + + println("PROBE032 first_frame=${firstFrame.await()}") + println("PROBE032 frames=${frames.joinToString("|")}") + assertEquals("head+tail", frames.last()) + } finally { + job.cancel() + } + } + + @Test + fun freshAttachDuringFrozenWriter_preAcceptanceWindow_recordFirstFrameThenConverge() = runTest { + val signals = MutableSharedFlow(replay = 1) + val projected = MutableStateFlow("head") + val dispatcher = FreezableDispatcher() + val overlay = + object : Overlay { + override val changes: Flow = signals + + override fun apply( + key: TestKey, + base: String?, + ): String = projected.value + } + val job = SupervisorJob(backgroundScope.coroutineContext[Job]) + val scope = CoroutineScope(dispatcher + job) + val engine = + KeyEngine( + key = key, + keyId = KeyId.from(key), + fetcher = ResultFetcher { FetcherResult.Success("base") }, + sot = SharedFlowSourceOfTruth(), + bookkeeper = InMemoryBookkeeper(), + validator = DefaultFreshnessValidator, + wallClock = FakeWallClock(0L), + engineScope = scope, + telemetry = null, + overlay = overlay, + ) + + try { + engine.stream(Freshness.CachedOrFetch).test { + awaitDataValue("head") + cancelAndIgnoreRemainingEvents() + } + println("PROBE032B phase1 initial projection 'head' observed") + withContext(Dispatchers.Default) { + signals.subscriptionCount.first { count -> count > 0 } + } + withContext(Dispatchers.Default) { delay(200) } + + dispatcher.freeze() + projected.value = "head+tail" + signals.emit(key) + println("PROBE032B phase2 writer frozen pre-acceptance; change emitted; snapshot should still be Ready('head')") + + val frames = mutableListOf() + val firstFrame = CompletableDeferred() + val watchdog = + backgroundScope.launch(Dispatchers.Default) { + delay(3_000) + if (!firstFrame.isCompleted) { + println("PROBE032B measurement=NO_FIRST_FRAME_WHILE_FROZEN (attach waited out the 3s freeze)") + } + dispatcher.thaw() + } + engine.stream(Freshness.LocalOnly).test { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data) { + frames += item.value + if (!firstFrame.isCompleted) { + firstFrame.complete(item.value) + if (dispatcher.isFrozen()) { + println("PROBE032B measurement=FIRST_FRAME_WHILE_FROZEN value=${item.value}") + } + dispatcher.thaw() + } + if (item.value == "head+tail") break + } else { + frames += item::class.simpleName.orEmpty() + } + } + cancelAndIgnoreRemainingEvents() + } + watchdog.cancel() + + println("PROBE032B first_frame=${firstFrame.await()}") + println("PROBE032B frames=${frames.joinToString("|")}") + assertEquals("head+tail", frames.last()) + } finally { + dispatcher.thaw() + job.cancel() + } + } + + private class FreezableDispatcher : kotlinx.coroutines.CoroutineDispatcher() { + private data class DispatcherState( + val frozen: Boolean, + val parked: List>, + ) + + private val state = MutableStateFlow(DispatcherState(frozen = false, parked = emptyList())) + + override fun dispatch( + context: kotlin.coroutines.CoroutineContext, + block: Runnable, + ) { + while (true) { + val current = state.value + if (!current.frozen) { + Dispatchers.Default.dispatch(context, block) + return + } + val next = current.copy(parked = current.parked + (context to block)) + if (state.compareAndSet(current, next)) return + } + } + + fun freeze() { + while (true) { + val current = state.value + if (state.compareAndSet(current, current.copy(frozen = true))) return + } + } + + fun isFrozen(): Boolean = state.value.frozen + + fun thaw() { + while (true) { + val current = state.value + if (state.compareAndSet(current, DispatcherState(frozen = false, parked = emptyList()))) { + current.parked.forEach { entry -> Dispatchers.Default.dispatch(entry.first, entry.second) } + return + } + } + } + } + + private class SuspendGate { + private val entered = CompletableDeferred() + private val released = CompletableDeferred() + + suspend fun pause() { + entered.complete(Unit) + released.await() + } + + suspend fun awaitEntered() { + withContext(Dispatchers.Default) { + entered.await() + } + } + + fun release() { + released.complete(Unit) + } + } + + private suspend fun app.cash.turbine.ReceiveTurbine>.awaitDataValue( + expected: String, + ): StoreResult.Data { + while (true) { + val item = awaitItem() + if (item is StoreResult.Data && item.value == expected) return item + } + } +} + +// Turbine's 3s default would nest inside the 25s shadow. Raising the Turbine deadline above +// the shadow makes runTest the only effective timeout. +private val TEST_TIMEOUT = 25.seconds +private val TURBINE_DEADLINE = 30.seconds // strictly > TEST_TIMEOUT: the shadow must fire first + +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = TEST_TIMEOUT) { + val scope = this + withTurbineTimeout(TURBINE_DEADLINE) { scope.testBody() } + } diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecordResolutionTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecordResolutionTest.kt new file mode 100644 index 000000000..17a0e37a2 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/ReaderRecordResolutionTest.kt @@ -0,0 +1,137 @@ +package org.mobilenativefoundation.store6.core.internal + +import org.mobilenativefoundation.store6.core.Origin +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame + +class ReaderRecordResolutionTest { + @Test + fun sameGenerationAndRevisionRow_resolvesTheLiveEqualResidence() { + val recorded = envelope("value") + val live = envelope("value") + + val resolved = + resolveCurrentRecord( + record = ReaderRecord.Row(recorded, readerGen = 4L, residenceRevision = 9L), + currentReaderGen = 4L, + currentResidence = live, + currentResidenceRevision = 9L, + ) + + val row = resolved as ReaderRecord.Row + assertSame(live, row.envelope) + assertEquals(9L, row.residenceRevision) + } + + @Test + fun equalContentReplayAtOlderRevision_resolvesTheFresherLiveEnvelope() { + val live = envelope("value") + + val resolved = + resolveCurrentRecord( + record = ReaderRecord.Row(envelope("value"), 4L, 7L), + currentReaderGen = 4L, + currentResidence = live, + currentResidenceRevision = 9L, + ) + + val row = resolved as ReaderRecord.Row + assertSame(live, row.envelope) + assertEquals(9L, row.residenceRevision) + } + + @Test + fun staleRowOrAbsentCannotOverwriteTheCurrentResidence() { + val live = envelope("new") + + assertNull( + resolveCurrentRecord( + record = ReaderRecord.Row(envelope("old"), 2L, 3L), + currentReaderGen = 2L, + currentResidence = live, + currentResidenceRevision = 4L, + ), + ) + assertNull( + resolveCurrentRecord( + record = ReaderRecord.Absent(2L, 3L), + currentReaderGen = 2L, + currentResidence = live, + currentResidenceRevision = 4L, + ), + ) + assertNull( + resolveCurrentRecord( + record = ReaderRecord.Row(envelope("old"), 1L, 3L), + currentReaderGen = 2L, + currentResidence = null, + currentResidenceRevision = 4L, + ), + ) + } + + @Test + fun queuedAbsentBeforeWriterEcho_isRejectedOnceTheLiveRowIsInstalled() { + val live = envelope("writer-echo") + + assertNull( + resolveCurrentRecord( + record = ReaderRecord.Absent(readerGen = 8L, residenceRevision = 10L), + currentReaderGen = 8L, + currentResidence = live, + currentResidenceRevision = 11L, + ), + ) + } + + @Test + fun postReservationRecheck_rejectsConcurrentEqualValueResidenceReplacement() { + val reserved = + ReaderRecord.Row( + envelope = envelope("same-content"), + readerGen = 5L, + residenceRevision = 11L, + ) + val replacement = + ReaderRecord.Row( + envelope = envelope("same-content"), + readerGen = 5L, + residenceRevision = 12L, + ) + + assertFalse(isSameResolvedRow(reserved, replacement)) + } + + @Test + fun memoryOriginOverride_rejectsConcurrentRevisionReplacement() { + val memory = envelope("same-content") + + assertFalse( + canRestampMemoryOrigin( + memoryEnvelope = memory, + memoryRevision = 3L, + currentEnvelope = memory, + currentRevision = 4L, + ), + ) + assertFalse( + canRestampMemoryOrigin( + memoryEnvelope = memory, + memoryRevision = 3L, + currentEnvelope = envelope("same-content"), + currentRevision = 3L, + ), + ) + } + + private fun envelope(value: String): ValueEnvelope = + ValueEnvelope( + value = value, + origin = Origin.SOT, + meta = null, + staleEpochAtCommit = 0L, + ) +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/RotatingSlotSourceOfTruth.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/RotatingSlotSourceOfTruth.kt new file mode 100644 index 000000000..08fcf7ab9 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/RotatingSlotSourceOfTruth.kt @@ -0,0 +1,118 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +/** + * Fault fixture that intentionally violates the source-of-truth reader-liveness contract. + * + * Per-key, namespace, and all destructive deletes rotate matching entries to new null-seeded slots + * without notifying collectors of the old slots. This is reserved for later engine recovery tests + * and must never receive a `SourceOfTruthContractKit` runner. + */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class RotatingSlotSourceOfTruth : SourceOfTruth { + private class Slot( + val rows: MutableSharedFlow, + val firstReaderDelivery: CompletableDeferred, + ) + + private class Entry( + var slot: Slot, + var subscriptionCount: Int, + ) + + private val lock = Mutex() + private val entries = HashMap>() + + override fun reader(key: K): Flow = + flow { + val captured = captureSlot(key) + captured.rows.collect { value -> + emit(value) + captured.firstReaderDelivery.complete(Unit) + } + } + + override suspend fun write( + key: K, + value: V, + ) { + val keyId = KeyId.from(key) + lock.withLock { + check(entryFor(keyId).slot.rows.tryEmit(value)) + } + } + + override suspend fun delete(key: K) { + val keyId = KeyId.from(key) + lock.withLock { + entryFor(keyId).slot = newSlot() + } + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + lock.withLock { + entries.forEach { (keyId, entry) -> + if (keyId.namespace == namespace.value) { + entry.slot = newSlot() + } + } + } + } + + override suspend fun deleteAll() { + lock.withLock { + entries.values.forEach { entry -> entry.slot = newSlot() } + } + } + + internal suspend fun subscriptionCount(key: K): Int { + val keyId = KeyId.from(key) + return lock.withLock { entries[keyId]?.subscriptionCount ?: 0 } + } + + /** Waits until the current slot's first row has crossed downstream raw-reader capture. */ + internal suspend fun awaitCurrentSlotReaderDelivery(key: K) { + val keyId = KeyId.from(key) + val delivery = lock.withLock { entryFor(keyId).slot.firstReaderDelivery } + delivery.await() + } + + private suspend fun captureSlot(key: K): Slot { + val keyId = KeyId.from(key) + return lock.withLock { + entryFor(keyId).also { entry -> + entry.subscriptionCount += 1 + }.slot + } + } + + private fun entryFor(keyId: KeyId): Entry = + entries.getOrPut(keyId) { + Entry(slot = newSlot(), subscriptionCount = 0) + } + + private fun newSlot(): Slot { + val rows = + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 64, + ) + check(rows.tryEmit(null)) + return Slot( + rows = rows, + firstReaderDelivery = CompletableDeferred(), + ) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/SharedFlowSourceOfTruth.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/SharedFlowSourceOfTruth.kt new file mode 100644 index 000000000..13fcdcf7c --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/SharedFlowSourceOfTruth.kt @@ -0,0 +1,89 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.seam.SourceOfTruth + +/** Reusable SharedFlow-backed source-of-truth fake exercised by the full contract kit. */ +@OptIn(DelicateStoreApi::class, ExperimentalStoreApi::class) +internal class SharedFlowSourceOfTruth( + private val beforeBulkEmission: suspend () -> Unit = {}, +) : SourceOfTruth { + private val slotsLock = Mutex() + private val mutationLock = Mutex() + private val slots = HashMap>() + + override fun reader(key: K): Flow = + flow { + emitAll(slotFor(key)) + } + + override suspend fun write( + key: K, + value: V, + ) { + update(key, value) + } + + override suspend fun delete(key: K) { + update(key, null) + } + + override suspend fun deleteNamespace(namespace: StoreNamespace) { + emitNullToSlots { keyId -> keyId.namespace == namespace.value } + } + + override suspend fun deleteAll() { + emitNullToSlots { true } + } + + private suspend fun update( + key: K, + row: V?, + ) { + currentCoroutineContext().ensureActive() + mutationLock.withLock { + withContext(NonCancellable) { + slotFor(key).emit(row) + } + } + } + + private suspend fun slotFor(key: K): MutableSharedFlow { + val keyId = KeyId.from(key) + return slotsLock.withLock { + slots.getOrPut(keyId) { newSlot() } + } + } + + private suspend fun emitNullToSlots(matches: (KeyId) -> Boolean) { + currentCoroutineContext().ensureActive() + mutationLock.withLock { + withContext(NonCancellable) { + val matchingSlots = slotsLock.withLock { slots.filterKeys(matches).toList() } + beforeBulkEmission() + matchingSlots.forEach { (_, slot) -> slot.emit(null) } + } + } + } + + private fun newSlot(): MutableSharedFlow = + MutableSharedFlow( + replay = 1, + extraBufferCapacity = 64, + ).also { slot -> + check(slot.tryEmit(null)) + } +} diff --git a/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/TransitionTest.kt b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/TransitionTest.kt new file mode 100644 index 000000000..a487d4982 --- /dev/null +++ b/store6-core/src/commonTest/kotlin/org/mobilenativefoundation/store6/core/internal/TransitionTest.kt @@ -0,0 +1,474 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.coroutines.CompletableDeferred +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreMeta +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame + +@OptIn(ExperimentalStoreApi::class) +class TransitionTest { + private fun ticket(): FetchTicket = FetchTicket(CompletableDeferred()) + + private fun meta( + writtenAtEpochMillis: Long = 10L, + etag: String? = "etag", + ): StoreMeta = EngineStoreMeta(writtenAtEpochMillis, etag) + + private fun commitFetch( + ticket: FetchTicket, + value: Any = "value", + meta: StoreMeta = meta(), + ): KeyEvent.CommitFetch = + KeyEvent.CommitFetch(ticket, value, meta) + + private fun attribution( + owner: FetchTicket = ticket(), + value: Any = "resident", + origin: Origin = Origin.SOT, + meta: StoreMeta = meta(), + staleEpochAtCommit: Long = 2L, + ): AttributionTag = + AttributionTag( + owner = owner, + value = value, + origin = origin, + meta = meta, + staleEpochAtCommit = staleEpochAtCommit, + ) + + @Test + fun attributionTag_equalPayloadInstances_areIdentityDistinct() { + val value = Any() + val meta = meta() + val first = attribution(value = value, meta = meta) + val second = attribution(value = value, meta = meta) + + assertNotSame(first, second) + assertNotEquals(first, second) + } + + @Test + fun ensureFetch_whenIdle_launchesWithFreshTicket() { + val fresh = ticket() + + val result = transition(KeyState.Initial, KeyEvent.EnsureFetch(fresh)) + + assertSame(fresh, assertIs(result.state.fetch).ticket) + assertSame(fresh, assertIs(result.effect).ticket) + } + + @Test + fun ensureFetch_whenInFlight_joinsExistingTicketWithoutChangingState() { + val existing = ticket() + val state = + KeyState.Initial.copy( + fetch = FetchSlot.InFlight(existing, clearEpochAtLaunch = 0L), + ) + + val result = transition(state, KeyEvent.EnsureFetch(ticket())) + + assertSame(state, result.state) + assertSame(existing, assertIs(result.effect).ticket) + } + + @Test + fun settleFetch_withMatchingTicket_returnsToIdle() { + val current = ticket() + val state = + KeyState.Initial.copy( + fetch = FetchSlot.InFlight(current, clearEpochAtLaunch = 2L), + staleEpoch = 1L, + clearEpoch = 2L, + ) + + val result = transition( + state, + KeyEvent.SettleFetch(current), + ) + + assertIs(result.state.fetch) + assertEquals(1L, result.state.staleEpoch) + assertEquals(2L, result.state.clearEpoch) + assertIs(result.effect) + } + + @Test + fun settleFetch_withStaleTicket_preservesCurrentFetch() { + val current = ticket() + val state = + KeyState.Initial.copy( + fetch = FetchSlot.InFlight(current, clearEpochAtLaunch = 0L), + ) + + val result = transition(state, KeyEvent.SettleFetch(ticket())) + + assertSame(state, result.state) + assertIs(result.effect) + } + + @Test + fun settleFetch_whenIdle_isIgnored() { + val result = transition(KeyState.Initial, KeyEvent.SettleFetch(ticket())) + + assertSame(KeyState.Initial, result.state) + assertIs(result.effect) + } + + @Test + fun ensureFetch_recordsClearEpochAtLaunch() { + val state = KeyState.Initial.copy(clearEpoch = 7L) + + val result = transition(state, KeyEvent.EnsureFetch(ticket())) + + assertEquals(7L, assertIs(result.state.fetch).clearEpochAtLaunch) + } + + @Test + fun commitFetch_matchingTicketAndEpoch_commitsAndSettles() { + val current = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(current, clearEpochAtLaunch = 0L), + staleEpoch = 3L, + ) + + val result = transition(state, commitFetch(current)) + + assertIs(result.effect) + assertIs(result.state.fetch) + assertEquals(3L, result.state.staleEpoch) // epochs preserved + assertEquals(0L, result.state.clearEpoch) + } + + @Test + fun commitFetch_matchingTicketAndEpoch_stampsValueBoundAttributionAtCommitEpoch() { + val current = ticket() + val value = Any() + val meta = meta(writtenAtEpochMillis = 123L, etag = "v2") + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(current, clearEpochAtLaunch = 4L), + staleEpoch = 7L, + clearEpoch = 4L, + readerGen = 9L, + ) + + val result = transition( + state, + commitFetch( + ticket = current, + value = value, + meta = meta, + ), + ) + + val tag = requireNotNull(result.state.attribution) + assertSame(value, tag.value) + assertEquals(Origin.FETCHER, tag.origin) + assertSame(meta, tag.meta) + assertEquals(7L, tag.staleEpochAtCommit) + assertEquals(9L, result.state.readerGen) + } + + @Test + fun commitFetch_afterClearAdvancedEpoch_isSuperseded() { + val current = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(current, clearEpochAtLaunch = 0L), + clearEpoch = 1L, + ) + + val result = transition(state, commitFetch(current)) + + assertIs(result.effect) + assertSame(state, result.state) // slot kept for the settle path + } + + @Test + fun commitFetch_staleTicket_isIgnored() { + val current = ticket() + val state = KeyState.Initial.copy(fetch = FetchSlot.InFlight(current, 0L)) + + val result = transition(state, commitFetch(ticket())) + + assertIs(result.effect) + assertSame(state, result.state) + } + + @Test + fun commitFetch_whenIdle_isIgnored() { + val result = transition(KeyState.Initial, commitFetch(ticket())) + + assertIs(result.effect) + } + + @Test + fun invalidate_bumpsOnlyStaleEpoch_andPreservesReaderGenerationAndAttribution() { + val tag = attribution() + val state = KeyState.Initial.copy(readerGen = 5L, attribution = tag) + + val result = transition(state, KeyEvent.Invalidate) + + assertIs(result.effect) + assertEquals(1L, result.state.staleEpoch) + assertEquals(0L, result.state.clearEpoch) + assertEquals(5L, result.state.readerGen) + assertSame(tag, result.state.attribution) + assertIs(result.state.fetch) + } + + @Test + fun clear_bumpsBothEpochsAndReaderGeneration_revokesAttribution() { + val state = KeyState.Initial.copy( + staleEpoch = 2L, + clearEpoch = 3L, + readerGen = 4L, + attribution = attribution(), + ) + + val result = transition(state, KeyEvent.Clear) + + assertIs(result.effect) + assertEquals(3L, result.state.staleEpoch) + assertEquals(4L, result.state.clearEpoch) + assertEquals(5L, result.state.readerGen) + assertNull(result.state.attribution) + } + + @Test + fun settleFetch_preservesEpochs() { + val current = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(current, clearEpochAtLaunch = 2L), + staleEpoch = 5L, + clearEpoch = 2L, + ) + + val result = transition(state, KeyEvent.SettleFetch(current)) + + assertIs(result.state.fetch) + assertEquals(5L, result.state.staleEpoch) // the Initial-reset bug must not return + assertEquals(2L, result.state.clearEpoch) + } + + @Test + fun settleFetch_afterClearAdvancedEpoch_isSupersededAndSettled() { + val current = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(current, clearEpochAtLaunch = 1L), + staleEpoch = 4L, + clearEpoch = 2L, + ) + + val result = transition(state, KeyEvent.SettleFetch(current)) + + assertIs(result.effect) + assertIs(result.state.fetch) + assertEquals(4L, result.state.staleEpoch) + assertEquals(2L, result.state.clearEpoch) + } + + @Test + fun settleFetch_afterCommitAlreadySettled_isIgnored() { + val current = ticket() + val committed = transition( + KeyState.Initial.copy(fetch = FetchSlot.InFlight(current, 0L)), + commitFetch(current), + ) + + val result = transition(committed.state, KeyEvent.SettleFetch(current)) + + assertIs(result.effect) + assertSame(committed.state, result.state) + } + + @Test + fun commitDeleted_matchingTicketAndEpoch_settlesAndRequestsDeleteCommit() { + val owner = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(owner, clearEpochAtLaunch = 0L), + readerGen = 6L, + attribution = attribution(), + ) + val result = transition(state, KeyEvent.CommitDeleted(owner)) + assertEquals(KeyEffect.CommitDelete, result.effect) + assertEquals(FetchSlot.Idle, result.state.fetch) + assertEquals(1L, result.state.clearEpoch) + assertEquals(0L, result.state.staleEpoch) // no stale bump: deletion must not drive refetch loops + assertEquals(7L, result.state.readerGen) + assertNull(result.state.attribution) + } + + @Test + fun commitDeleted_afterClearAdvancedEpoch_isSuperseded() { + val owner = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(owner, clearEpochAtLaunch = 0L), + staleEpoch = 1L, + clearEpoch = 1L, + ) + val result = transition(state, KeyEvent.CommitDeleted(owner)) + assertEquals(KeyEffect.Superseded, result.effect) + assertSame(state, result.state) + } + + @Test + fun commitDeleted_staleTicket_isIgnored() { + val state = KeyState.Initial.copy(fetch = FetchSlot.InFlight(ticket(), clearEpochAtLaunch = 0L)) + val result = transition(state, KeyEvent.CommitDeleted(ticket())) + assertEquals(KeyEffect.Ignored, result.effect) + assertSame(state, result.state) + } + + @Test + fun commitDeleted_whenIdle_isIgnored() { + val result = transition(KeyState.Initial, KeyEvent.CommitDeleted(ticket())) + assertEquals(KeyEffect.Ignored, result.effect) + assertSame(KeyState.Initial, result.state) + } + + @Test + fun commitDeleted_preservesStaleEpochUnderPriorInvalidations() { + val owner = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(owner, clearEpochAtLaunch = 2L), + staleEpoch = 5L, + clearEpoch = 2L, + readerGen = 8L, + attribution = attribution(), + ) + val result = transition(state, KeyEvent.CommitDeleted(owner)) + assertEquals(KeyEffect.CommitDelete, result.effect) + assertEquals(5L, result.state.staleEpoch) + assertEquals(3L, result.state.clearEpoch) + assertEquals(9L, result.state.readerGen) + assertNull(result.state.attribution) + } + + @Test + fun commitRevalidated_matchingTicketAndEpoch_settlesAndRevokesAttribution() { + val owner = ticket() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(owner, clearEpochAtLaunch = 2L), + staleEpoch = 5L, + clearEpoch = 2L, + readerGen = 8L, + attribution = attribution(), + ) + + val result = transition(state, KeyEvent.CommitRevalidated(owner)) + + assertEquals(KeyEffect.CommitRevalidation, result.effect) + assertEquals(FetchSlot.Idle, result.state.fetch) + assertEquals(5L, result.state.staleEpoch) + assertEquals(2L, result.state.clearEpoch) + assertEquals(8L, result.state.readerGen) + assertNull(result.state.attribution) + } + + @Test + fun commitRevalidated_afterClearAdvancedEpoch_isSupersededWithoutRevokingAttribution() { + val owner = ticket() + val tag = attribution() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(owner, clearEpochAtLaunch = 1L), + clearEpoch = 2L, + attribution = tag, + ) + + val result = transition(state, KeyEvent.CommitRevalidated(owner)) + + assertEquals(KeyEffect.Superseded, result.effect) + assertSame(state, result.state) + assertSame(tag, result.state.attribution) + } + + @Test + fun commitRevalidated_staleTicket_isIgnoredWithoutRevokingAttribution() { + val tag = attribution() + val state = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(ticket(), clearEpochAtLaunch = 0L), + attribution = tag, + ) + + val result = transition(state, KeyEvent.CommitRevalidated(ticket())) + + assertEquals(KeyEffect.Ignored, result.effect) + assertSame(state, result.state) + assertSame(tag, result.state.attribution) + } + + @Test + fun applyWrite_stampsSotAttribution_preservingSlotAndEpochs() { + val inFlight = KeyState.Initial.copy( + fetch = FetchSlot.InFlight(FetchTicket(CompletableDeferred()), 0L), + staleEpoch = 3L, + clearEpoch = 1L, + ) + val meta = EngineStoreMeta(writtenAtEpochMillis = 7L, etag = null) + val writeTicket = FetchTicket(CompletableDeferred()) + + val result = transition( + inFlight, + KeyEvent.ApplyWrite(ticket = writeTicket, value = "v", meta = meta), + ) + + assertEquals(KeyEffect.CommitWrite, result.effect) + assertEquals(inFlight.fetch, result.state.fetch) + assertEquals(3L, result.state.staleEpoch) + assertEquals(1L, result.state.clearEpoch) + val tag = assertNotNull(result.state.attribution) + assertSame(writeTicket, tag.owner) + assertEquals(Origin.SOT, tag.origin) + assertEquals(3L, tag.staleEpochAtCommit) + } + + @Test + fun consumeAttribution_whenPresent_returnsThenClearsTag() { + val tag = attribution() + val state = KeyState.Initial.copy(attribution = tag) + + val result = transition(state, KeyEvent.ConsumeAttribution(tag)) + + assertSame(tag, assertIs(result.effect).tag) + assertNull(result.state.attribution) + } + + @Test + fun consumeAttribution_whenAbsent_returnsNullWithoutChangingStateInstance() { + val state = KeyState.Initial.copy(readerGen = 4L) + + val result = transition(state, KeyEvent.ConsumeAttribution(null)) + + assertNull(assertIs(result.effect).tag) + assertSame(state, result.state) + } + + @Test + fun consumeAttribution_observedBeforeCurrentTag_doesNotConsumeTheLaterTag() { + val later = attribution() + val state = KeyState.Initial.copy(attribution = later) + + val result = transition(state, KeyEvent.ConsumeAttribution(observed = null)) + + assertNull(assertIs(result.effect).tag) + assertSame(state, result.state) + assertSame(later, result.state.attribution) + } + + @Test + fun revokeAttribution_clearsTag() { + val state = KeyState.Initial.copy(attribution = attribution()) + + val result = transition(state, KeyEvent.RevokeAttribution) + + assertEquals(KeyEffect.AttributionRevoked, result.effect) + assertNull(result.state.attribution) + } +} diff --git a/store6-core/src/jsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.js.kt b/store6-core/src/jsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.js.kt new file mode 100644 index 000000000..686162571 --- /dev/null +++ b/store6-core/src/jsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.js.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store6.core.internal + +import kotlin.js.Date + +/** Returns the JavaScript system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = Date.now().toLong() diff --git a/store6-core/src/jvmMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.jvm.kt b/store6-core/src/jvmMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.jvm.kt new file mode 100644 index 000000000..87b67fae6 --- /dev/null +++ b/store6-core/src/jvmMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.jvm.kt @@ -0,0 +1,4 @@ +package org.mobilenativefoundation.store6.core.internal + +/** Returns the JVM system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/store6-core/src/jvmTest/kotlin/org/mobilenativefoundation/store6/core/FetcherDispatcherTest.kt b/store6-core/src/jvmTest/kotlin/org/mobilenativefoundation/store6/core/FetcherDispatcherTest.kt new file mode 100644 index 000000000..404289b00 --- /dev/null +++ b/store6-core/src/jvmTest/kotlin/org/mobilenativefoundation/store6/core/FetcherDispatcherTest.kt @@ -0,0 +1,27 @@ +package org.mobilenativefoundation.store6.core + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertNotSame + +class FetcherDispatcherTest { + @Test + fun fetcherSynchronousWork_runsOffTheCallerThread() = runBlocking { + val callerThread = Thread.currentThread() + lateinit var fetcherThread: Thread + val store = store { + fetcher { + fetcherThread = Thread.currentThread() + "value" + } + } + + try { + store.get(TestKey("1")) + + assertNotSame(callerThread, fetcherThread) + } finally { + store.close() + } + } +} diff --git a/store6-core/src/linuxMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.linux.kt b/store6-core/src/linuxMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.linux.kt new file mode 100644 index 000000000..71e1fbbb5 --- /dev/null +++ b/store6-core/src/linuxMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.linux.kt @@ -0,0 +1,18 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import platform.posix.gettimeofday +import platform.posix.timeval + +/** Returns the Linux system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = + memScoped { + val now = alloc() + gettimeofday(now.ptr, null) + (now.tv_sec.toLong() * 1_000L) + (now.tv_usec.toLong() / 1_000L) + } diff --git a/store6-core/src/mingwMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.mingw.kt b/store6-core/src/mingwMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.mingw.kt new file mode 100644 index 000000000..c9cf4e98c --- /dev/null +++ b/store6-core/src/mingwMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.mingw.kt @@ -0,0 +1,21 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package org.mobilenativefoundation.store6.core.internal + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import platform.windows.FILETIME +import platform.windows.GetSystemTimeAsFileTime + +/** Returns the Windows system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = + memScoped { + val fileTime = alloc() + GetSystemTimeAsFileTime(fileTime.ptr) + val ticks = + (fileTime.dwHighDateTime.toLong() shl 32) or + fileTime.dwLowDateTime.toLong() + (ticks / 10_000L) - 11_644_473_600_000L + } diff --git a/store6-core/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.wasmJs.kt b/store6-core/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.wasmJs.kt new file mode 100644 index 000000000..267e153cd --- /dev/null +++ b/store6-core/src/wasmJsMain/kotlin/org/mobilenativefoundation/store6/core/internal/WallClock.wasmJs.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store6.core.internal + +private fun jsDateNow(): Double = js("Date.now()") + +/** Returns the Wasm-JS system wall clock in Unix epoch milliseconds. */ +internal actual fun currentEpochMillis(): Long = jsDateNow().toLong() diff --git a/store6-devtools-demo/README.md b/store6-devtools-demo/README.md new file mode 100644 index 000000000..28f5c4fcb --- /dev/null +++ b/store6-devtools-demo/README.md @@ -0,0 +1,75 @@ +# store6-devtools-demo + +An unpublished reference-app seed for the Store6 devtools experience. It runs the same in-process +inspector on desktop, Android, and iOS. The inspector uses no host tooling or transport: no sockets, +desktop host, or web panel. + +## Desktop + +```shell +./gradlew :store6-devtools-demo:run +``` + +## Android + +Connect an emulator or device, choose its serial explicitly, then build, install, and launch: + +```shell +adb devices +./gradlew :store6-devtools-demo:assembleDebug +ANDROID_SERIAL= ./gradlew :store6-devtools-demo:installDebug +adb -s shell am start -n org.mobilenativefoundation.store6.devtoolsdemo/.MainActivity +``` + +Replace `` with one `device` entry from `adb devices`. Explicit selection avoids installing +or launching against the wrong connected device. + +## iOS + +The Kotlin framework acceptance command is: + +```shell +./gradlew :store6-devtools-demo:linkDebugFrameworkIosSimulatorArm64 +``` + +The committed Xcode host lives under `iosApp/`. Open `iosApp/iosApp.xcodeproj`, select an iOS +simulator, and run scheme `iosApp`. + +To recreate the shell, use these exact settings: + +1. In `store6-devtools-demo/iosApp`, create an iOS App project named `iosApp` with scheme `iosApp`. +2. Set bundle identifier `org.mobilenativefoundation.store6.devtoolsdemo.iosApp` and deployment + target iOS 15. +3. Keep the committed `iosApp/Info.plist`, including + `CADisableMinimumFrameDurationOnPhone` as a Boolean `YES`. +4. In the target build settings for both Debug and Release, set **Generate Info.plist File** to + `No` and **Info.plist File** to `Info.plist`. +5. Add this Run Script build phase **before Compile Sources**: + + ```shell + cd "$SRCROOT/../.." && ./gradlew :store6-devtools-demo:embedAndSignAppleFrameworkForXcode + ``` + +6. Add framework search path + `$(SRCROOT)/../build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)`. +7. Keep the existing `iosApp/iosApp/iOSApp.swift` and + `iosApp/iosApp/ContentView.swift` sources. + +From the repository root, run the exact Xcode acceptance command: + +```shell +cd store6-devtools-demo +xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator build +``` + +## Android and iOS manual checklist + +Run all six steps on **both** Android and iOS: + +1. Launch the app and open the inspector with the FAB. +2. Confirm the key appears as `FRESH` with its age ticking. +3. Set latency to 3000 ms, tap **Invalidate**, and confirm `STALE` then `FETCHING` plus refreshed + content. +4. Enable failure, tap **Invalidate**, and confirm `ERROR` plus `fetch_failed`. +5. Tap **Clear** and confirm `CLEARED`. +6. Confirm logcat or the Xcode console contains Store6 v0 logger lines. diff --git a/store6-devtools-demo/build.gradle.kts b/store6-devtools-demo/build.gradle.kts new file mode 100644 index 000000000..c490a4f12 --- /dev/null +++ b/store6-devtools-demo/build.gradle.kts @@ -0,0 +1,72 @@ +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget + +plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.android.application") + alias(libs.plugins.kotlin.compose.compiler) + alias(libs.plugins.jetbrains.compose) +} + +kotlin { + jvmToolchain(11) + androidTarget() + jvm("desktop") + listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { target: KotlinNativeTarget -> + target.binaries.framework { + baseName = "DevtoolsDemo" + isStatic = true + } + } + + sourceSets { + val commonMain by getting { + dependencies { + implementation(projects.store6Core) + implementation(projects.store6Testing) + implementation(projects.store6Compose) + implementation(projects.store6Devtools) + implementation(projects.store6DevtoolsInspector) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + } + } + val androidMain by getting { + dependencies { + // Direct coordinate by design: unpublished demo, catalog untouched. + implementation("androidx.activity:activity-compose:1.10.1") + } + } + val desktopMain by getting { + dependencies { implementation(compose.desktop.currentOs) } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.test) + } + } + } +} + +compose.desktop { + application { + mainClass = "org.mobilenativefoundation.store6.devtoolsdemo.MainKt" + } +} + +android { + namespace = "org.mobilenativefoundation.store6.devtoolsdemo" + compileSdk = 36 + defaultConfig { + applicationId = "org.mobilenativefoundation.store6.devtoolsdemo" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "0.1" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} diff --git a/store6-devtools-demo/iosApp/.gitignore b/store6-devtools-demo/iosApp/.gitignore new file mode 100644 index 000000000..257645df7 --- /dev/null +++ b/store6-devtools-demo/iosApp/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +xcuserdata/ +*.xcuserstate +xcschememanagement.plist diff --git a/store6-devtools-demo/iosApp/Info.plist b/store6-devtools-demo/iosApp/Info.plist new file mode 100644 index 000000000..8c3120bbe --- /dev/null +++ b/store6-devtools-demo/iosApp/Info.plist @@ -0,0 +1,53 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UILaunchScreen + + + UISupportedInterfaceOrientations~iphone + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/store6-devtools-demo/iosApp/iosApp.xcodeproj/project.pbxproj b/store6-devtools-demo/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 000000000..49ea45cbe --- /dev/null +++ b/store6-devtools-demo/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,361 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + C93CE112301681D000D2EC83 /* iosApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + C93CE114301681D000D2EC83 /* iosApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = iosApp; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + C93CE10F301681D000D2EC83 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + C93CE109301681D000D2EC83 = { + isa = PBXGroup; + children = ( + C93CE114301681D000D2EC83 /* iosApp */, + C93CE113301681D000D2EC83 /* Products */, + ); + sourceTree = ""; + }; + C93CE113301681D000D2EC83 /* Products */ = { + isa = PBXGroup; + children = ( + C93CE112301681D000D2EC83 /* iosApp.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + C93CE111301681D000D2EC83 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = C93CE11D301681D100D2EC83 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + C93CE1303016848000D2EC83 /* Embed And Sign Apple Framework For Xcode */, + C93CE10E301681D000D2EC83 /* Sources */, + C93CE10F301681D000D2EC83 /* Frameworks */, + C93CE110301681D000D2EC83 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + C93CE114301681D000D2EC83 /* iosApp */, + ); + name = iosApp; + packageProductDependencies = ( + ); + productName = iosApp; + productReference = C93CE112301681D000D2EC83 /* iosApp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + C93CE10A301681D000D2EC83 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2660; + LastUpgradeCheck = 2660; + TargetAttributes = { + C93CE111301681D000D2EC83 = { + CreatedOnToolsVersion = 26.6; + }; + }; + }; + buildConfigurationList = C93CE10D301681D000D2EC83 /* Build configuration list for PBXProject "iosApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = C93CE109301681D000D2EC83; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = C93CE113301681D000D2EC83 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + C93CE111301681D000D2EC83 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + C93CE110301681D000D2EC83 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + C93CE1303016848000D2EC83 /* Embed And Sign Apple Framework For Xcode */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Embed And Sign Apple Framework For Xcode"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "cd \"$SRCROOT/../..\"\n./gradlew :store6-devtools-demo:embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + C93CE10E301681D000D2EC83 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + C93CE11B301681D100D2EC83 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + C93CE11C301681D100D2EC83 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/../build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + C93CE11E301681D100D2EC83 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.mobilenativefoundation.store6.devtoolsdemo.iosApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + C93CE11F301681D100D2EC83 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.mobilenativefoundation.store6.devtoolsdemo.iosApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + C93CE10D301681D000D2EC83 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C93CE11B301681D100D2EC83 /* Debug */, + C93CE11C301681D100D2EC83 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C93CE11D301681D100D2EC83 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C93CE11E301681D100D2EC83 /* Debug */, + C93CE11F301681D100D2EC83 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = C93CE10A301681D000D2EC83 /* Project object */; +} diff --git a/store6-devtools-demo/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/store6-devtools-demo/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/store6-devtools-demo/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/Contents.json b/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/store6-devtools-demo/iosApp/iosApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/store6-devtools-demo/iosApp/iosApp/ContentView.swift b/store6-devtools-demo/iosApp/iosApp/ContentView.swift new file mode 100644 index 000000000..577edce74 --- /dev/null +++ b/store6-devtools-demo/iosApp/iosApp/ContentView.swift @@ -0,0 +1,10 @@ +import SwiftUI +import DevtoolsDemo + +struct ContentView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + MainViewControllerKt.MainViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} diff --git a/store6-devtools-demo/iosApp/iosApp/iOSApp.swift b/store6-devtools-demo/iosApp/iosApp/iOSApp.swift new file mode 100644 index 000000000..22a927fa3 --- /dev/null +++ b/store6-devtools-demo/iosApp/iosApp/iOSApp.swift @@ -0,0 +1,8 @@ +import SwiftUI + +@main +struct iOSApp: App { + var body: some Scene { + WindowGroup { ContentView() } + } +} diff --git a/store6-devtools-demo/src/androidMain/AndroidManifest.xml b/store6-devtools-demo/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..5d8d4c0e9 --- /dev/null +++ b/store6-devtools-demo/src/androidMain/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/store6-devtools-demo/src/androidMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainActivity.kt b/store6-devtools-demo/src/androidMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainActivity.kt new file mode 100644 index 000000000..8c39290c4 --- /dev/null +++ b/store6-devtools-demo/src/androidMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainActivity.kt @@ -0,0 +1,12 @@ +package org.mobilenativefoundation.store6.devtoolsdemo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { DemoApp() } + } +} diff --git a/store6-devtools-demo/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoApp.kt b/store6-devtools-demo/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoApp.kt new file mode 100644 index 000000000..5633bb781 --- /dev/null +++ b/store6-devtools-demo/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoApp.kt @@ -0,0 +1,154 @@ +@file:OptIn(ExperimentalStoreApi::class, DelicateStoreApi::class) + +package org.mobilenativefoundation.store6.devtoolsdemo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.mobilenativefoundation.store6.compose.collectAsStateWithLifecycle +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.Store +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.core.StoreResult +import org.mobilenativefoundation.store6.core.seam.Fetcher +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import org.mobilenativefoundation.store6.core.store +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor +import org.mobilenativefoundation.store6.devtools.StoreTelemetryLogger +import org.mobilenativefoundation.store6.devtools.storeTelemetryOf +import org.mobilenativefoundation.store6.devtools.compose.StoreInspectorOverlay +import org.mobilenativefoundation.store6.testing.FakeFetcher + +class UserKey(val id: String) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace("users") + + override fun canonicalId(): String = id +} + +data class User(val id: String, val name: String) + +/** Live knobs the demo screen mutates while the store keeps fetching. */ +class DemoControls { + val latencyMillis = MutableStateFlow(1500L) + val failFetches = MutableStateFlow(false) +} + +/** Toggleable latency/failure around a store6-testing [FakeFetcher]; refetches visibly change. */ +class DemoFetcher( + private val controls: DemoControls, + val delegate: FakeFetcher = FakeFetcher(), +) : Fetcher { + private var version = 0 + + init { + delegate.onUnscripted = { key, _ -> + version += 1 + FetcherResult.Success(User(key.id, "User ${key.id} (v$version)")) + } + } + + override suspend fun fetch(key: UserKey, etag: String?): FetcherResult { + delay(controls.latencyMillis.value) + if (controls.failFetches.value) { + return FetcherResult.Error(IllegalStateException("Demo failure toggle is on")) + } + return delegate.fetch(key, etag) + } +} + +/** Process-wide demo graph: one store, one monitor, ONE builder line installing both sinks. */ +object DemoGraph { + val controls = DemoControls() + val monitor = StoreDevtoolsMonitor() + val users: Store = store { + fetcher(DemoFetcher(controls)) + telemetry(storeTelemetryOf(StoreTelemetryLogger(), monitor)) + } +} + +@Composable +fun DemoApp() { + MaterialTheme { + StoreInspectorOverlay(DemoGraph.monitor) { + DemoScreen(DemoGraph.users, DemoGraph.controls) + } + } +} + +@Composable +private fun DemoScreen(store: Store, controls: DemoControls) { + val scope = rememberCoroutineScope() + val key = remember { UserKey("1") } + val result by store.collectAsStateWithLifecycle(key) + + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + when (val current = result) { + is StoreResult.Data -> Card { + Column(Modifier.padding(16.dp)) { + Text(current.value.name, style = MaterialTheme.typography.headlineSmall) + Text( + "origin=${current.origin} stale=${current.isStale} " + + "refreshing=${current.refreshing}", + ) + } + } + is StoreResult.Loading -> Text("Loading…") + is StoreResult.Error -> Text( + "Error (servedStale=${current.servedStale})", + color = MaterialTheme.colorScheme.error, + ) + is StoreResult.Revalidated -> Text("Revalidated (age ${current.age})") + } + + val latency by controls.latencyMillis.collectAsState() + Text("Fetch latency: ${latency}ms") + Slider( + value = latency.toFloat(), + onValueChange = { controls.latencyMillis.value = it.toLong() }, + modifier = Modifier.semantics { + contentDescription = "Fetch latency in milliseconds" + }, + valueRange = 0f..5000f, + ) + val failing by controls.failFetches.collectAsState() + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Fail fetches") + Switch( + checked = failing, + onCheckedChange = { controls.failFetches.value = it }, + modifier = Modifier.semantics { contentDescription = "Fail fetches" }, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { scope.launch { store.invalidate(key) } }) { Text("Invalidate") } + Button(onClick = { scope.launch { store.clear(key) } }) { Text("Clear") } + } + } +} diff --git a/store6-devtools-demo/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoFetcherTest.kt b/store6-devtools-demo/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoFetcherTest.kt new file mode 100644 index 000000000..79faffcda --- /dev/null +++ b/store6-devtools-demo/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/DemoFetcherTest.kt @@ -0,0 +1,56 @@ +@file:OptIn( + ExperimentalStoreApi::class, + DelicateStoreApi::class, + kotlinx.coroutines.ExperimentalCoroutinesApi::class, +) + +package org.mobilenativefoundation.store6.devtoolsdemo + +import kotlinx.coroutines.test.TestResult +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest as coroutineRunTest +import org.mobilenativefoundation.store6.core.DelicateStoreApi +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.core.seam.FetcherResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Duration.Companion.seconds + +class DemoFetcherTest { + @Test + fun failureToggleProducesFetcherError(): TestResult = runTest { + val controls = DemoControls().apply { failFetches.value = true } + val fetcher = DemoFetcher(controls) + + assertIs(fetcher.fetch(UserKey("1"), etag = null)) + } + + @Test + fun unscriptedFetchesProduceVersionedUsersAndScriptedResultsWin(): TestResult = runTest { + val controls = DemoControls().apply { latencyMillis.value = 3000L } + val fetcher = DemoFetcher(controls) + val key = UserKey("1") + + val first = fetcher.fetch(key, etag = null) + assertIs>(first) + assertEquals("User 1 (v1)", first.value.name) + assertEquals(3000L, currentTime) + + val second = fetcher.fetch(key, etag = null) + assertIs>(second) + assertEquals("User 1 (v2)", second.value.name) + assertEquals(6000L, currentTime) + + fetcher.delegate.enqueue(key, FetcherResult.Success(User("1", "Scripted"))) + val third = fetcher.fetch(key, etag = null) + assertIs>(third) + assertEquals("Scripted", third.value.name) + assertEquals(9000L, currentTime) + } +} + +// One file-private 25s runTest shadow, no nested wall-clock waits. +private fun runTest(testBody: suspend TestScope.() -> Unit): TestResult = + coroutineRunTest(timeout = 25.seconds, testBody = testBody) diff --git a/store6-devtools-demo/src/desktopMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/Main.kt b/store6-devtools-demo/src/desktopMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/Main.kt new file mode 100644 index 000000000..42f167cc5 --- /dev/null +++ b/store6-devtools-demo/src/desktopMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/Main.kt @@ -0,0 +1,7 @@ +package org.mobilenativefoundation.store6.devtoolsdemo + +import androidx.compose.ui.window.singleWindowApplication + +fun main() { + singleWindowApplication(title = "store6 devtools demo") { DemoApp() } +} diff --git a/store6-devtools-demo/src/desktopTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/IosAppProjectConfigurationTest.kt b/store6-devtools-demo/src/desktopTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/IosAppProjectConfigurationTest.kt new file mode 100644 index 000000000..0fa392367 --- /dev/null +++ b/store6-devtools-demo/src/desktopTest/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/IosAppProjectConfigurationTest.kt @@ -0,0 +1,77 @@ +package org.mobilenativefoundation.store6.devtoolsdemo + +import java.nio.file.Files +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class IosAppProjectConfigurationTest { + @Test + fun targetUsesCommittedInfoPlistWithRequiredComposeBoolean() { + val projectFile = + listOf( + Path.of("iosApp", "iosApp.xcodeproj", "project.pbxproj"), + Path.of("store6-devtools-demo", "iosApp", "iosApp.xcodeproj", "project.pbxproj"), + ).firstOrNull(Files::isRegularFile) + assertNotNull(projectFile, "Could not find the committed iosApp Xcode project") + + val project = Files.readString(projectFile) + val targetConfigurationList = + Regex( + """(?s)[A-F0-9]+ /\* Build configuration list for PBXNativeTarget "iosApp" \*/ = \{\s*""" + + """isa = XCConfigurationList;\s*buildConfigurations = \((.*?)\);""", + ).find(project)?.groupValues?.get(1) + assertNotNull(targetConfigurationList, "Could not find the iosApp target configuration list") + + val configurations = + Regex("""([A-F0-9]+) /\* (Debug|Release) \*/""") + .findAll(targetConfigurationList) + .associate { match -> match.groupValues[2] to match.groupValues[1] } + assertEquals(setOf("Debug", "Release"), configurations.keys) + + configurations.forEach { (name, id) -> + val buildSettings = + Regex( + """(?s)\b$id /\* $name \*/ = \{.*?buildSettings = \{(.*?)""" + + """\n\s*};\n\s*name = $name;""", + ).find(project)?.groupValues?.get(1) + assertNotNull(buildSettings, "Could not find $name iosApp target build settings") + assertTrue( + Regex("""(?m)^\s*GENERATE_INFOPLIST_FILE = NO;\s*$""") + .containsMatchIn(buildSettings), + "$name iosApp target must disable generated Info.plist", + ) + assertTrue( + Regex("""(?m)^\s*INFOPLIST_FILE = Info\.plist;\s*$""") + .containsMatchIn(buildSettings), + "$name iosApp target must use the project-root Info.plist", + ) + } + + val infoPlist = projectFile.parent.parent.resolve("Info.plist") + assertTrue(Files.isRegularFile(infoPlist), "Could not find the committed project-root Info.plist") + val infoPlistContents = Files.readString(infoPlist) + assertTrue( + Regex( + """(?s)\s*CADisableMinimumFrameDurationOnPhone\s*\s*""", + ).containsMatchIn(infoPlistContents), + "iosApp/Info.plist must set CADisableMinimumFrameDurationOnPhone to boolean true", + ) + assertTrue( + Regex( + """(?s)\s*UILaunchScreen\s*\s*\s*""" + + """\s*UILaunchScreen\s*\s*\s*""", + ).containsMatchIn(infoPlistContents), + "iosApp/Info.plist must preserve the generated launch-screen dictionary", + ) + assertTrue( + Regex( + """(?s)\s*UISupportedInterfaceOrientations~iphone\s*\s*.*?""" + + """""", + ).containsMatchIn(infoPlistContents), + "iosApp/Info.plist must preserve the iPhone-specific orientations", + ) + } +} diff --git a/store6-devtools-demo/src/iosMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainViewController.kt b/store6-devtools-demo/src/iosMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainViewController.kt new file mode 100644 index 000000000..258409a30 --- /dev/null +++ b/store6-devtools-demo/src/iosMain/kotlin/org/mobilenativefoundation/store6/devtoolsdemo/MainViewController.kt @@ -0,0 +1,6 @@ +package org.mobilenativefoundation.store6.devtoolsdemo + +import androidx.compose.ui.window.ComposeUIViewController +import platform.UIKit.UIViewController + +fun MainViewController(): UIViewController = ComposeUIViewController { DemoApp() } diff --git a/store6-devtools-inspector/README.md b/store6-devtools-inspector/README.md new file mode 100644 index 000000000..423147b35 --- /dev/null +++ b/store6-devtools-inspector/README.md @@ -0,0 +1,72 @@ +# store6-devtools-inspector + +An in-process Compose Multiplatform inspector for a `StoreDevtoolsMonitor`. The published artifact +is `@ExperimentalStoreApi` and reads the monitor's event-derived `StateFlow`; it does not inspect +Store internals. + +## Dependency + +```kotlin +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation( + "org.mobilenativefoundation.store:store6-devtools-inspector:6.0.0-SNAPSHOT", + ) + } + } + } +} +``` + +## Entry points + +```kotlin +import androidx.compose.runtime.Composable +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor +import org.mobilenativefoundation.store6.devtools.compose.StoreInspector +import org.mobilenativefoundation.store6.devtools.compose.StoreInspectorOverlay + +@OptIn(ExperimentalStoreApi::class) +@Composable +fun InspectorOnly(monitor: StoreDevtoolsMonitor) { + StoreInspector(monitor) +} + +@OptIn(ExperimentalStoreApi::class) +@Composable +fun AppWithInspector( + monitor: StoreDevtoolsMonitor, + content: @Composable () -> Unit, +) { + StoreInspectorOverlay(monitor = monitor, content = content) +} +``` + +Install the same `monitor` in the Store builder with `telemetry(monitor)`, or combine it with +another sink through `storeTelemetryOf(...)`. `StoreInspector` renders the inspector directly. +`StoreInspectorOverlay` wraps application content with a floating toggle and a lower-half +inspector panel. The two examples are alternative hosts. + +The final target subset is Android, JVM, `iosArm64`, `iosSimulatorArm64`, `iosX64`, +`macosArm64`, JS, and WasmJS. JS uses Node. WasmJS uses a browser test/runtime because the Compose +UI dependency graph is browser-only on this toolchain. No other Store6 targets are published by +this artifact. + +## What it shows + +The current tabs are: + +- **Keys:** namespace, canonical key, derived state, last served origin, and observed-success age. +- **Timeline:** chronological per-key rows labelled with the derived state after that event, the + exact v0 event kind, and elapsed offset. The header shows the key's current derived state and + age. A served event retains the prior row's state; when retained history begins with a serve, + its row is `OBSERVED` because the inspector does not reconstruct dropped history. +- **Events:** the retained event log, newest first, with dropped-event accounting. + +The timeline is a table of telemetry-derived rows, not a freshness chart. It cannot infer policy +staleness or events that were never observed. The tab model leaves room for a future **Outbox** +view; it is not implemented here. Everything stays in process: there are no sockets, host tools, +or web panel. diff --git a/store6-devtools-inspector/api/android/store6-devtools-inspector.api b/store6-devtools-inspector/api/android/store6-devtools-inspector.api new file mode 100644 index 000000000..35363d8bb --- /dev/null +++ b/store6-devtools-inspector/api/android/store6-devtools-inspector.api @@ -0,0 +1,5 @@ +public final class org/mobilenativefoundation/store6/devtools/compose/StoreInspectorKt { + public static final fun StoreInspector (Lorg/mobilenativefoundation/store6/devtools/StoreDevtoolsMonitor;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun StoreInspectorOverlay (Lorg/mobilenativefoundation/store6/devtools/StoreDevtoolsMonitor;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + diff --git a/store6-devtools-inspector/api/jvm/store6-devtools-inspector.api b/store6-devtools-inspector/api/jvm/store6-devtools-inspector.api new file mode 100644 index 000000000..35363d8bb --- /dev/null +++ b/store6-devtools-inspector/api/jvm/store6-devtools-inspector.api @@ -0,0 +1,5 @@ +public final class org/mobilenativefoundation/store6/devtools/compose/StoreInspectorKt { + public static final fun StoreInspector (Lorg/mobilenativefoundation/store6/devtools/StoreDevtoolsMonitor;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun StoreInspectorOverlay (Lorg/mobilenativefoundation/store6/devtools/StoreDevtoolsMonitor;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + diff --git a/store6-devtools-inspector/api/store6-devtools-inspector.klib.api b/store6-devtools-inspector/api/store6-devtools-inspector.klib.api new file mode 100644 index 000000000..06dc72197 --- /dev/null +++ b/store6-devtools-inspector/api/store6-devtools-inspector.klib.api @@ -0,0 +1,10 @@ +// Klib ABI Dump +// Targets: [iosArm64, iosSimulatorArm64, iosX64, js, macosArm64, wasmJs] +// Rendering settings: +// - Signature version: 2 +// - Show manifest properties: true +// - Show declarations: true + +// Library unique name: +final fun org.mobilenativefoundation.store6.devtools.compose/StoreInspector(org.mobilenativefoundation.store6.devtools/StoreDevtoolsMonitor, androidx.compose.ui/Modifier?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // org.mobilenativefoundation.store6.devtools.compose/StoreInspector|StoreInspector(org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor;androidx.compose.ui.Modifier?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun org.mobilenativefoundation.store6.devtools.compose/StoreInspectorOverlay(org.mobilenativefoundation.store6.devtools/StoreDevtoolsMonitor, androidx.compose.ui/Modifier?, kotlin/Boolean, kotlin/Function2, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // org.mobilenativefoundation.store6.devtools.compose/StoreInspectorOverlay|StoreInspectorOverlay(org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor;androidx.compose.ui.Modifier?;kotlin.Boolean;kotlin.Function2;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] diff --git a/store6-devtools-inspector/build.gradle.kts b/store6-devtools-inspector/build.gradle.kts new file mode 100644 index 000000000..ac0f31f10 --- /dev/null +++ b/store6-devtools-inspector/build.gradle.kts @@ -0,0 +1,63 @@ +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + id("org.mobilenativefoundation.store.store6.multiplatform.subset") + alias(libs.plugins.kotlin.compose.compiler) + alias(libs.plugins.jetbrains.compose) +} + +// CMP 1.8.2 gates these pinned targets via project-local properties at task-graph time. +extra["org.jetbrains.compose.experimental.macos.enabled"] = true +extra["org.jetbrains.compose.experimental.jscanvas.enabled"] = true + +val store6StabilityConfig = + rootProject.layout.projectDirectory.file("store6-compose/stability/store6-stability.conf") + +composeCompiler { + stabilityConfigurationFiles.add(store6StabilityConfig) +} + +tasks.withType>().configureEach { + inputs.file(store6StabilityConfig).withPathSensitivity(PathSensitivity.RELATIVE) +} + +kotlin { + androidTarget() + jvm() + iosX64() + iosArm64() + iosSimulatorArm64() + macosArm64() + js { nodejs() } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { browser() } + + sourceSets { + val commonMain by getting { + dependencies { + api(projects.store6Devtools) + api(compose.runtime) + api(compose.ui) + implementation(compose.foundation) + implementation(compose.material3) + } + } + val commonTest by getting { + dependencies { + implementation(projects.store6Testing) + implementation(libs.kotlinx.coroutines.test) + } + } + val jvmTest by getting { + dependencies { + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + implementation(compose.uiTest) + implementation(compose.desktop.currentOs) + } + } + } +} + +android { + namespace = "org.mobilenativefoundation.store6.devtools.compose" +} diff --git a/store6-devtools-inspector/gradle.properties b/store6-devtools-inspector/gradle.properties new file mode 100644 index 000000000..2af57aacd --- /dev/null +++ b/store6-devtools-inspector/gradle.properties @@ -0,0 +1,3 @@ +VERSION_NAME=6.0.0-SNAPSHOT +POM_NAME=store6-devtools-inspector +POM_ARTIFACT_ID=store6-devtools-inspector diff --git a/multicast/config/ktlint/baseline.xml b/store6-devtools-inspector/src/androidMain/AndroidManifest.xml similarity index 51% rename from multicast/config/ktlint/baseline.xml rename to store6-devtools-inspector/src/androidMain/AndroidManifest.xml index 981420778..8072ee00d 100644 --- a/multicast/config/ktlint/baseline.xml +++ b/store6-devtools-inspector/src/androidMain/AndroidManifest.xml @@ -1,3 +1,2 @@ - - + diff --git a/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorState.kt b/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorState.kt new file mode 100644 index 000000000..3270a98ee --- /dev/null +++ b/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorState.kt @@ -0,0 +1,35 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.devtools.compose + +import org.mobilenativefoundation.store6.devtools.DevtoolsKeyEntry + +/** + * Inspector tabs. + * + * The Phase-2 mutation outbox view lands as a fourth entry here, preserving the planned + * information-architecture slot without affecting the v0 inspector. + */ +internal enum class InspectorTab { + Keys, + Timeline, + Events, +} + +internal data class SelectedKey( + val namespace: String, + val key: String, +) + +internal data class InspectorState( + val tab: InspectorTab = InspectorTab.Keys, + val selected: SelectedKey? = null, +) { + fun withTab(tab: InspectorTab): InspectorState = copy(tab = tab) + + fun withKeySelected(entry: DevtoolsKeyEntry): InspectorState = + copy( + tab = InspectorTab.Timeline, + selected = SelectedKey(entry.namespace, entry.key), + ) +} diff --git a/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorUiState.kt b/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorUiState.kt new file mode 100644 index 000000000..040b023bd --- /dev/null +++ b/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorUiState.kt @@ -0,0 +1,147 @@ +@file:OptIn(org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class) + +package org.mobilenativefoundation.store6.devtools.compose + +import org.mobilenativefoundation.store6.core.StoreError +import org.mobilenativefoundation.store6.devtools.DevtoolsKeyState +import org.mobilenativefoundation.store6.devtools.DevtoolsSnapshot +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsEvent +import kotlin.time.Duration + +internal class KeyRowUi( + val namespace: String, + val key: String, + val stateLabel: String, + val originLabel: String, + val ageLabel: String, + val isSelected: Boolean, +) + +internal class EventRowUi( + val seq: Long, + val atLabel: String, + val kindLabel: String, + val stateLabel: String?, + val keyLabel: String, + val detailLabel: String, +) + +internal class InspectorUiState( + val tab: InspectorTab, + val keyRows: List, + val timelineHeader: String?, + val timelineRows: List, + val eventRows: List, + val dropNotice: String?, + val emptyHint: String?, +) + +internal fun deriveInspectorUiState( + snapshot: DevtoolsSnapshot, + now: Duration, + state: InspectorState, +): InspectorUiState { + val keyRows = snapshot.keys.map { entry -> + KeyRowUi( + namespace = entry.namespace, + key = entry.key, + stateLabel = entry.state.name, + originLabel = entry.lastOrigin?.name ?: "—", + ageLabel = ageLabel(entry.lastFetchSucceededAt, now), + isSelected = state.selected?.let { + it.namespace == entry.namespace && it.key == entry.key + } == true, + ) + } + val selectedEntry = state.selected?.let { selected -> + snapshot.keys.firstOrNull { + it.namespace == selected.namespace && it.key == selected.key + } + } + val timelineRows = state.selected?.let { selected -> + timelineEventRows( + snapshot.events.filter { + it.namespace == selected.namespace && it.key == selected.key + }, + ) + }.orEmpty() + return InspectorUiState( + tab = state.tab, + keyRows = keyRows, + timelineHeader = selectedEntry?.let { + "${it.namespace} / ${it.key} — ${it.state.name}, ${ageLabel(it.lastFetchSucceededAt, now)}" + }, + timelineRows = timelineRows, + eventRows = snapshot.events.asReversed().map(::eventRow), + dropNotice = snapshot.droppedEvents.takeIf { it > 0 } + ?.let { "$it older events dropped" }, + emptyHint = if (snapshot.lastSeq == 0L) { + "No events yet — install with telemetry(monitor) in your store {} builder." + } else { + null + }, + ) +} + +private fun timelineEventRows(events: List): List { + var state = DevtoolsKeyState.OBSERVED + return events.map { event -> + state = when (event) { + is StoreDevtoolsEvent.FetchStarted -> DevtoolsKeyState.FETCHING + is StoreDevtoolsEvent.FetchSucceeded -> DevtoolsKeyState.FRESH + is StoreDevtoolsEvent.FetchFailed -> DevtoolsKeyState.ERROR + is StoreDevtoolsEvent.Served -> state + is StoreDevtoolsEvent.Invalidated -> DevtoolsKeyState.STALE + is StoreDevtoolsEvent.Cleared -> DevtoolsKeyState.CLEARED + } + eventRow(event, state.name) + } +} + +private fun eventRow( + event: StoreDevtoolsEvent, + stateLabel: String? = null, +): EventRowUi { + val (kind, detail) = when (event) { + is StoreDevtoolsEvent.FetchStarted -> "fetch_started" to "" + is StoreDevtoolsEvent.FetchSucceeded -> + "fetch_succeeded" to "fetch_ms=${event.fetchDuration.inWholeMilliseconds}" + is StoreDevtoolsEvent.FetchFailed -> + "fetch_failed" to + "fetch_ms=${event.fetchDuration.inWholeMilliseconds} error=${errorV0Name(event.error)}" + is StoreDevtoolsEvent.Served -> "serve" to "origin=${event.origin.name}" + is StoreDevtoolsEvent.Invalidated -> "invalidate" to "" + is StoreDevtoolsEvent.Cleared -> "clear" to "" + } + return EventRowUi( + seq = event.seq, + atLabel = elapsedLabel(event.at), + kindLabel = kind, + stateLabel = stateLabel, + keyLabel = "${event.namespace}/${event.key}", + detailLabel = detail, + ) +} + +private fun errorV0Name(error: StoreError): String = + when (error) { + is StoreError.Fetch -> "Fetch" + is StoreError.Persistence -> "Persistence" + is StoreError.Conversion -> "Conversion" + is StoreError.FreshnessUnsatisfiable -> "FreshnessUnsatisfiable" + is StoreError.Conflict -> "Conflict" + is StoreError.Missing -> "Missing" + } + +internal fun ageLabel( + anchor: Duration?, + now: Duration, +): String = anchor + ?.let { (now - it).coerceAtLeast(Duration.ZERO) } + ?.let { "age ${elapsedLabel(it)}" } + ?: "age unknown" + +internal fun elapsedLabel(elapsed: Duration): String { + val tenths = elapsed.inWholeMilliseconds / 100 + return "${tenths / 10}.${tenths % 10}s" +} diff --git a/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspector.kt b/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspector.kt new file mode 100644 index 000000000..565a0639f --- /dev/null +++ b/store6-devtools-inspector/src/commonMain/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspector.kt @@ -0,0 +1,188 @@ +package org.mobilenativefoundation.store6.devtools.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor +import kotlin.time.Duration.Companion.seconds + +/** + * In-app inspector over a [StoreDevtoolsMonitor]: key browser, per-key timeline, and event log. + * + * State shown here is event-derived; policy staleness (`MaxAge` expiry) emits no event and is + * never inferred. Zero transport: this composable reads the monitor's `StateFlow`, nothing else. + * The telemetry seam it observes remains a freeze candidate. + */ +@ExperimentalStoreApi +@Composable +public fun StoreInspector( + monitor: StoreDevtoolsMonitor, + modifier: Modifier = Modifier, +) { + val snapshot by monitor.state.collectAsState() + var state by remember { mutableStateOf(InspectorState()) } + var now by remember { mutableStateOf(monitor.elapsedNow()) } + LaunchedEffect(monitor) { + while (true) { + now = monitor.elapsedNow() + delay(1.seconds) + } + } + val ui = deriveInspectorUiState(snapshot, now, state) + + Column(modifier.fillMaxSize()) { + TabRow(selectedTabIndex = ui.tab.ordinal) { + InspectorTab.entries.forEach { tab -> + Tab( + selected = ui.tab == tab, + onClick = { state = state.withTab(tab) }, + text = { Text(tab.name) }, + ) + } + } + ui.emptyHint?.let { Text(it, Modifier.padding(16.dp)) } + when (ui.tab) { + InspectorTab.Keys -> LazyColumn(Modifier.fillMaxSize()) { + items( + ui.keyRows, + key = { inspectorItemKey(namespace = it.namespace, key = it.key) }, + ) { row -> + val entry = snapshot.keys.first { + it.namespace == row.namespace && it.key == row.key + } + Row( + Modifier + .fillMaxWidth() + .clickable { state = state.withKeySelected(entry) } + .background( + if (row.isSelected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surface + }, + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text("${row.namespace} / ${row.key}", Modifier.weight(1f)) + Text("${row.stateLabel} · ${row.originLabel} · ${row.ageLabel}") + } + } + } + + InspectorTab.Timeline -> Column(Modifier.fillMaxSize()) { + Text( + ui.timelineHeader ?: "Select a key in the Keys tab", + Modifier.padding(12.dp), + style = MaterialTheme.typography.titleSmall, + ) + EventList(ui.timelineRows) + } + + InspectorTab.Events -> Column(Modifier.fillMaxSize()) { + ui.dropNotice?.let { Text(it, Modifier.padding(horizontal = 12.dp)) } + EventList(ui.eventRows) + } + } + } +} + +internal fun inspectorItemKey( + namespace: String, + key: String, +): String = "${namespace.length}:$namespace${key.length}:$key" + +@Composable +private fun EventList(rows: List) { + LazyColumn(Modifier.fillMaxSize()) { + items(rows, key = { it.seq }) { row -> + if (row.stateLabel == null) { + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) { + Text(row.atLabel, Modifier.padding(end = 8.dp)) + Text(row.kindLabel, Modifier.weight(1f)) + Text("${row.keyLabel} ${row.detailLabel}".trim()) + } + } else { + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) { + Text(row.atLabel, Modifier.padding(end = 8.dp)) + Column(Modifier.weight(1f)) { + Row(Modifier.fillMaxWidth()) { + Text(row.stateLabel, Modifier.padding(end = 8.dp)) + Text(row.kindLabel) + } + Text("${row.keyLabel} ${row.detailLabel}".trim()) + } + } + } + } + } +} + +/** + * Wraps app [content] with a floating toggle and [StoreInspector] panel over the lower half. + * + * The overlay is the in-app delivery: no host tooling and no transport. + */ +@ExperimentalStoreApi +@Composable +public fun StoreInspectorOverlay( + monitor: StoreDevtoolsMonitor, + modifier: Modifier = Modifier, + initiallyOpen: Boolean = false, + content: @Composable () -> Unit, +) { + var open by remember { mutableStateOf(initiallyOpen) } + Box(modifier.fillMaxSize()) { + content() + if (open) { + Surface( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .fillMaxHeight(0.5f), + tonalElevation = 3.dp, + ) { + StoreInspector(monitor) + } + } + FloatingActionButton( + onClick = { open = !open }, + Modifier + .align(Alignment.BottomEnd) + .padding(16.dp) + .semantics { + contentDescription = + if (open) "Close Store6 inspector" else "Open Store6 inspector" + }, + ) { + Text(if (open) "×" else "S6") + } + } +} diff --git a/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/DeriveInspectorUiStateTest.kt b/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/DeriveInspectorUiStateTest.kt new file mode 100644 index 000000000..80fdf7229 --- /dev/null +++ b/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/DeriveInspectorUiStateTest.kt @@ -0,0 +1,318 @@ +/** + * Honesty pin: policy or `MaxAge` staleness emits no event and is not inferred. `FRESH` means only + * that no invalidation, clear, or failure has been observed since the latest success. + */ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.devtools.compose + +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor +import org.mobilenativefoundation.store6.testing.TestStoreResults +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TestTimeSource + +class DeriveInspectorUiStateTest { + @Test + fun futureSuccessAnchorClampsOnlyAgeDeltaToZero() { + val clock = TestTimeSource() + val monitor = StoreDevtoolsMonitor(timeSource = clock) + clock += 5.seconds + monitor.onFetchSucceeded(TestKey("users", "user-1"), 120.milliseconds) + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = 4.seconds, + state = InspectorState(), + ) + + assertEquals("age 0.0s", ui.keyRows.single().ageLabel) + assertEquals("5.0s", ui.eventRows.single().atLabel) + } + + @Test + fun saveableItemKeysDistinguishDelimiterCollisions() { + val first = inspectorItemKey(namespace = "a", key = "b/c") + val second = inspectorItemKey(namespace = "a/b", key = "c") + + assertEquals("1:a3:b/c", first) + assertEquals("3:a/b1:c", second) + assertNotEquals(first, second) + } + + @Test + fun keyRowsExposeEventDerivedStateOriginAndAge() { + val clock = TestTimeSource() + val monitor = StoreDevtoolsMonitor(timeSource = clock) + val fresh = TestKey("users", "user-1") + val unknown = TestKey("users", "user-2") + monitor.onFetchSucceeded(fresh, 120.milliseconds) + monitor.onServe(fresh, Origin.SOT) + monitor.onServe(unknown, Origin.MEMORY) + clock += 2_300.milliseconds + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState(), + ) + + val freshRow = ui.keyRows.single { it.key == "user-1" } + assertEquals("FRESH", freshRow.stateLabel) + assertEquals("SOT", freshRow.originLabel) + assertEquals("age 2.3s", freshRow.ageLabel) + val unknownRow = ui.keyRows.single { it.key == "user-2" } + assertEquals("OBSERVED", unknownRow.stateLabel) + assertEquals("MEMORY", unknownRow.originLabel) + assertEquals("age unknown", unknownRow.ageLabel) + } + + @Test + fun timelineFiltersTheSelectedKeyOldestToNewestWithExactHeader() { + val clock = TestTimeSource() + val monitor = StoreDevtoolsMonitor(timeSource = clock) + val selectedKey = TestKey("users", "user-1") + monitor.onFetchStarted(selectedKey) + clock += 100.milliseconds + monitor.onServe(TestKey("users", "user-2"), Origin.MEMORY) + clock += 100.milliseconds + monitor.onFetchSucceeded(selectedKey, 90.milliseconds) + clock += 2_300.milliseconds + val selectedEntry = monitor.state.value.keys.single { it.key == "user-1" } + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState().withKeySelected(selectedEntry), + ) + + assertEquals("users / user-1 — FRESH, age 2.3s", ui.timelineHeader) + assertEquals(listOf(1L, 3L), ui.timelineRows.map { it.seq }) + assertEquals(listOf("0.0s", "0.2s"), ui.timelineRows.map { it.atLabel }) + assertEquals( + listOf("fetch_started", "fetch_succeeded"), + ui.timelineRows.map { it.kindLabel }, + ) + assertEquals(listOf("FETCHING", "FRESH"), ui.timelineRows.map { it.stateLabel }) + } + + @Test + fun timelineRowsLabelEveryStateChangingEventWithItsDerivedState() { + val monitor = StoreDevtoolsMonitor() + val key = TestKey("users", "user-1") + monitor.onFetchSucceeded(key, 120.milliseconds) + monitor.onInvalidated(key) + monitor.onFetchStarted(key) + monitor.onFetchFailed(key, TestStoreResults.fetchError("offline"), 340.milliseconds) + monitor.onCleared(key) + val selectedEntry = monitor.state.value.keys.single() + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState().withKeySelected(selectedEntry), + ) + + assertEquals( + listOf( + "FRESH", + "STALE", + "FETCHING", + "ERROR", + "CLEARED", + ), + ui.timelineRows.map { it.stateLabel }, + ) + assertEquals( + listOf( + "fetch_ms=120", + "", + "", + "fetch_ms=340 error=Fetch", + "", + ), + ui.timelineRows.map { it.detailLabel }, + ) + assertEquals( + listOf("fetch_succeeded", "invalidate", "fetch_started", "fetch_failed", "clear"), + ui.timelineRows.map { it.kindLabel }, + ) + } + + @Test + fun timelineServedRowsRetainPriorStateOrUseObservedWhenFirst() { + val monitor = StoreDevtoolsMonitor() + val key = TestKey("users", "user-1") + monitor.onServe(key, Origin.MEMORY) + monitor.onInvalidated(TestKey("users", "user-2")) + monitor.onFetchStarted(key) + monitor.onServe(key, Origin.SOT) + monitor.onInvalidated(key) + monitor.onServe(key, Origin.MEMORY) + val selectedEntry = monitor.state.value.keys.single { it.key == "user-1" } + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState().withKeySelected(selectedEntry), + ) + + assertEquals(listOf(1L, 3L, 4L, 5L, 6L), ui.timelineRows.map { it.seq }) + assertEquals( + listOf( + "OBSERVED", + "FETCHING", + "FETCHING", + "STALE", + "STALE", + ), + ui.timelineRows.map { it.stateLabel }, + ) + assertEquals( + listOf("origin=MEMORY", "", "origin=SOT", "", "origin=MEMORY"), + ui.timelineRows.map { it.detailLabel }, + ) + assertEquals( + listOf("serve", "fetch_started", "serve", "invalidate", "serve"), + ui.timelineRows.map { it.kindLabel }, + ) + } + + @Test + fun timelineDoesNotReconstructStateFromHistoryDroppedBeforeItsFirstServe() { + val monitor = StoreDevtoolsMonitor(capacity = 2, timeSource = TestTimeSource()) + val key = TestKey("users", "user-1") + monitor.onFetchSucceeded(key, 120.milliseconds) + monitor.onServe(key, Origin.MEMORY) + monitor.onServe(TestKey("users", "user-2"), Origin.SOT) + val selectedEntry = monitor.state.value.keys.single { it.key == "user-1" } + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState().withKeySelected(selectedEntry), + ) + + assertEquals(1, monitor.state.value.droppedEvents) + assertEquals("users / user-1 — FRESH, age 0.0s", ui.timelineHeader) + assertEquals(listOf(2L), ui.timelineRows.map { it.seq }) + assertEquals(listOf("OBSERVED"), ui.timelineRows.map { it.stateLabel }) + assertEquals(listOf("origin=MEMORY"), ui.timelineRows.map { it.detailLabel }) + } + + @Test + fun eventsAreNewestFirstUseV0KindsAndReportDroppedRows() { + val monitor = StoreDevtoolsMonitor(capacity = 6) + val key = TestKey("users", "user-1") + monitor.onServe(key, Origin.MEMORY) + monitor.onServe(key, Origin.MEMORY) + monitor.onFetchStarted(key) + monitor.onFetchSucceeded(key, 120.milliseconds) + monitor.onFetchFailed(key, TestStoreResults.fetchError("offline"), 340.milliseconds) + monitor.onServe(key, Origin.FETCHER) + monitor.onInvalidated(key) + monitor.onCleared(key) + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState(tab = InspectorTab.Events), + ) + + assertEquals(listOf(8L, 7L, 6L, 5L, 4L, 3L), ui.eventRows.map { it.seq }) + assertEquals( + listOf( + "clear", + "invalidate", + "serve", + "fetch_failed", + "fetch_succeeded", + "fetch_started", + ), + ui.eventRows.map { it.kindLabel }, + ) + assertEquals(emptyList(), ui.eventRows.mapNotNull { it.stateLabel }) + assertEquals("origin=FETCHER", ui.eventRows.single { it.kindLabel == "serve" }.detailLabel) + assertEquals( + "fetch_ms=340 error=Fetch", + ui.eventRows.single { it.kindLabel == "fetch_failed" }.detailLabel, + ) + assertEquals( + "fetch_ms=120", + ui.eventRows.single { it.kindLabel == "fetch_succeeded" }.detailLabel, + ) + assertEquals("2 older events dropped", ui.dropNotice) + } + + @Test + fun fetchFailureDetailsUseTheExhaustiveV0ErrorNames() { + val monitor = StoreDevtoolsMonitor() + val key = TestKey("users", "user-1") + val errors = listOf( + TestStoreResults.fetchError("fetch"), + TestStoreResults.persistenceError("persistence"), + TestStoreResults.conversionError("conversion"), + TestStoreResults.freshnessUnsatisfiable("freshness"), + TestStoreResults.conflict(serverMeta = null, message = "conflict"), + TestStoreResults.missing(key, "missing"), + ) + errors.forEach { monitor.onFetchFailed(key, it, 15.milliseconds) } + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState(tab = InspectorTab.Events), + ) + + assertEquals( + listOf( + "fetch_ms=15 error=Missing", + "fetch_ms=15 error=Conflict", + "fetch_ms=15 error=FreshnessUnsatisfiable", + "fetch_ms=15 error=Conversion", + "fetch_ms=15 error=Persistence", + "fetch_ms=15 error=Fetch", + ), + ui.eventRows.map { it.detailLabel }, + ) + } + + @Test + fun emptyMonitorShowsTheExactInstallHint() { + val monitor = StoreDevtoolsMonitor() + + val ui = deriveInspectorUiState( + snapshot = monitor.state.value, + now = monitor.elapsedNow(), + state = InspectorState(), + ) + + assertEquals( + "No events yet — install with telemetry(monitor) in your store {} builder.", + ui.emptyHint, + ) + assertEquals(emptyList(), ui.keyRows) + assertEquals(emptyList(), ui.eventRows) + assertNull(ui.dropNotice) + assertNull(ui.timelineHeader) + } + + private class TestKey( + namespace: String, + private val id: String, + ) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace(namespace) + + override fun canonicalId(): String = id + } +} diff --git a/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorStateTest.kt b/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorStateTest.kt new file mode 100644 index 000000000..85e2ec329 --- /dev/null +++ b/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/InspectorStateTest.kt @@ -0,0 +1,81 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.devtools.compose + +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration + +class InspectorStateTest { + @Test + fun tabSelectionPreservesTheSelectedKey() { + val entry = entry("users", "user-1") + val selected = InspectorState().withKeySelected(entry) + + val events = selected.withTab(InspectorTab.Events) + + assertEquals(InspectorTab.Events, events.tab) + assertEquals(SelectedKey("users", "user-1"), events.selected) + } + + @Test + fun selectingAKeyJumpsToTimelineAndRecordsItsIdentity() { + val selected = InspectorState(tab = InspectorTab.Events) + .withKeySelected(entry("users", "user-1")) + + assertEquals(InspectorTab.Timeline, selected.tab) + assertEquals(SelectedKey("users", "user-1"), selected.selected) + } + + @Test + fun reselectingTheSameKeyIsIdempotent() { + val entry = entry("users", "user-1") + val selectedOnce = InspectorState().withKeySelected(entry) + + val selectedTwice = selectedOnce.withKeySelected(entry) + + assertEquals(selectedOnce, selectedTwice) + } + + @Test + fun selectionSurvivesSnapshotGrowth() { + val monitor = StoreDevtoolsMonitor() + val selectedKey = TestKey("users", "user-1") + monitor.onServe(selectedKey, Origin.MEMORY) + val state = InspectorState().withKeySelected(monitor.state.value.keys.single()) + + monitor.onServe(TestKey("users", "user-2"), Origin.FETCHER) + val ui = deriveInspectorUiState(monitor.state.value, Duration.ZERO, state) + + assertEquals(2, ui.keyRows.size) + assertTrue(ui.keyRows.single { it.key == "user-1" }.isSelected) + assertFalse(ui.keyRows.single { it.key == "user-2" }.isSelected) + assertEquals(SelectedKey("users", "user-1"), state.selected) + } + + private fun entry( + namespace: String, + key: String, + ) = StoreDevtoolsMonitor().run { + onServe(TestKey(namespace, key), Origin.MEMORY) + state.value.keys.single() + } + + private class TestKey( + namespace: String, + private val id: String, + ) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace(namespace) + + override fun canonicalId(): String = id + } +} diff --git a/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/docs/GuideInspectorSnippet.kt b/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/docs/GuideInspectorSnippet.kt new file mode 100644 index 000000000..2b94715ea --- /dev/null +++ b/store6-devtools-inspector/src/commonTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/docs/GuideInspectorSnippet.kt @@ -0,0 +1,24 @@ +package org.mobilenativefoundation.store6.devtools.compose.docs + +// docs:snippet:guides-devtools-inspector-hosts +import androidx.compose.runtime.Composable +import org.mobilenativefoundation.store6.core.ExperimentalStoreApi +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor +import org.mobilenativefoundation.store6.devtools.compose.StoreInspector +import org.mobilenativefoundation.store6.devtools.compose.StoreInspectorOverlay + +@OptIn(ExperimentalStoreApi::class) +@Composable +fun InspectorOnly(monitor: StoreDevtoolsMonitor) { + StoreInspector(monitor) +} + +@OptIn(ExperimentalStoreApi::class) +@Composable +fun AppWithInspector( + monitor: StoreDevtoolsMonitor, + content: @Composable () -> Unit, +) { + StoreInspectorOverlay(monitor = monitor, content = content) +} +// docs:snippet:end diff --git a/store6-devtools-inspector/src/jvmTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspectorSmokeTest.kt b/store6-devtools-inspector/src/jvmTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspectorSmokeTest.kt new file mode 100644 index 000000000..eee7b6eff --- /dev/null +++ b/store6-devtools-inspector/src/jvmTest/kotlin/org/mobilenativefoundation/store6/devtools/compose/StoreInspectorSmokeTest.kt @@ -0,0 +1,150 @@ +@file:OptIn( + org.mobilenativefoundation.store6.core.DelicateStoreApi::class, + org.mobilenativefoundation.store6.core.ExperimentalStoreApi::class, +) + +package org.mobilenativefoundation.store6.devtools.compose + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import org.mobilenativefoundation.store6.core.Origin +import org.mobilenativefoundation.store6.core.StoreKey +import org.mobilenativefoundation.store6.core.StoreNamespace +import org.mobilenativefoundation.store6.devtools.StoreDevtoolsMonitor +import org.mobilenativefoundation.store6.testing.TestStoreResults +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalTestApi::class) +class StoreInspectorSmokeTest { + @Test + fun inspectorCollectsMonitorUpdatesAfterComposition() = runComposeUiTest { + val monitor = StoreDevtoolsMonitor() + + setContent { StoreInspector(monitor) } + + onAllNodesWithText("users / user-1").assertCountEquals(0) + runOnIdle { + monitor.onFetchStarted(TestKey("users", "user-1")) + monitor.onServe(TestKey("users", "user-1"), Origin.MEMORY) + } + onNodeWithText("users / user-1").assertIsDisplayed() + } + + @Test + fun inspectorRendersBothDelimiterCollisionIdentities() = runComposeUiTest { + val monitor = StoreDevtoolsMonitor() + monitor.onServe(TestKey("a", "b/c"), Origin.MEMORY) + monitor.onServe(TestKey("a/b", "c"), Origin.MEMORY) + + setContent { StoreInspector(monitor) } + + onNodeWithText("a / b/c").assertIsDisplayed() + onNodeWithText("a/b / c").assertIsDisplayed() + } + + @Test + fun timelineRendersEachHistoricalDerivedStateAsAnExactLabel() = runComposeUiTest { + val monitor = StoreDevtoolsMonitor() + val key = TestKey("users", "user-1") + monitor.onFetchSucceeded(key, 1.milliseconds) + monitor.onInvalidated(key) + monitor.onFetchStarted(key) + monitor.onFetchFailed(key, TestStoreResults.fetchError("offline"), 1.milliseconds) + monitor.onCleared(key) + monitor.onFetchSucceeded(key, 1.milliseconds) + + setContent { StoreInspector(monitor) } + + onNodeWithText("users / user-1").performClick() + onNodeWithText("STALE").assertIsDisplayed() + onNodeWithText("FETCHING").assertIsDisplayed() + onNodeWithText("ERROR").assertIsDisplayed() + onNodeWithText("CLEARED").assertIsDisplayed() + } + + @Test + fun compactTimelineKeepsStateAndKindAboveTheKeyDetailLine() = runComposeUiTest { + val monitor = StoreDevtoolsMonitor() + monitor.onInvalidated(TestKey("u", "1")) + + setContent { + StoreInspector( + monitor = monitor, + modifier = Modifier.width(220.dp).height(480.dp), + ) + } + + onNodeWithText("u / 1").performClick() + val state = onNodeWithText("STALE").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + val kind = onNodeWithText("invalidate").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + val keyDetail = onNodeWithText("u/1").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + + assertTrue(kind.top == state.top, "state and exact v0 kind must share the first line") + assertTrue( + keyDetail.top >= maxOf(state.bottom, kind.bottom), + "key/detail must remain visible on a second line at compact widths", + ) + } + + @Test + fun compactEventsKeepTheOriginalWeightedKindAndNeverRenderState() = runComposeUiTest { + val monitor = StoreDevtoolsMonitor() + monitor.onInvalidated(TestKey("u", "1")) + + setContent { + StoreInspector( + monitor = monitor, + modifier = Modifier.width(220.dp).height(480.dp), + ) + } + + onNodeWithText("Events").performClick() + onAllNodesWithText("STALE").assertCountEquals(0) + val kind = onNodeWithText("invalidate").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + val keyDetail = onNodeWithText("u/1").assertIsDisplayed().fetchSemanticsNode().boundsInRoot + + assertTrue( + kind.width > keyDetail.width, + "Events must retain the original weighted-kind, fixed-detail row layout", + ) + } + + @Test + fun overlayFabExposesStateDependentSemanticsAndOpensThePanel() = runComposeUiTest { + val monitor = StoreDevtoolsMonitor() + + setContent { + StoreInspectorOverlay(monitor) { + Text("Host content") + } + } + + onNodeWithContentDescription("Open Store6 inspector") + .assertIsDisplayed() + .performClick() + onNodeWithText("Keys").assertIsDisplayed() + onNodeWithContentDescription("Close Store6 inspector").assertIsDisplayed() + } + + private class TestKey( + namespace: String, + private val id: String, + ) : StoreKey { + override val namespace: StoreNamespace = StoreNamespace(namespace) + + override fun canonicalId(): String = id + } +} diff --git a/store6-devtools/EVENTS.md b/store6-devtools/EVENTS.md new file mode 100644 index 000000000..c05d48386 --- /dev/null +++ b/store6-devtools/EVENTS.md @@ -0,0 +1,90 @@ +# Store6 devtools event vocabulary v0 + +This vocabulary is versioned but **EXPERIMENTAL**. Its names and field order are stable within v0. +The Store 6.1 web-panel wire format will stabilize from this vocabulary and is deliberately +**not** decided here. + +The logger and monitor observe identities and lifecycle facts only. Logger lines and inspector +presentation never include stored values or `StoreError` message and cause payloads. The +in-memory monitor projection does retain the structured `StoreError`; application code holding +`monitor.state` can inspect that object, including its message and cause. + +## Logger line + +Each event is one line with this order: + +```text +